From e10ef2241d951b1d9a399ed0ee09a336234df4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 7 Apr 2023 08:31:37 +0200 Subject: [PATCH 001/729] Transformations: Improve UX and fix refId issues (#65982) * Transformations: Improve UX and fix refId issues * Show query names and frame names in description * move to main grafan UI component * Added unit test * Fix lint error --------- Co-authored-by: Ryan McKinley --- .../FieldsByFrameRefIdMatcher.test.tsx | 54 +++++++ .../MatchersUI/FieldsByFrameRefIdMatcher.tsx | 153 ++++++++++++------ .../TransformationFilter.tsx | 19 +-- .../geomap/editor/FrameSelectionEditor.tsx | 79 +-------- 4 files changed, 179 insertions(+), 126 deletions(-) create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx new file mode 100644 index 00000000000..c8ab4606ea7 --- /dev/null +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { toDataFrame, FieldType } from '@grafana/data'; + +import { RefIDPicker, Props } from './FieldsByFrameRefIdMatcher'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const frame1 = toDataFrame({ + refId: 'A', + name: 'Series A', + fields: [], +}); + +const frame2 = toDataFrame({ + refId: 'A', + fields: [{ name: 'Value', type: FieldType.number, values: [10, 200], config: { displayName: 'Second series' } }], +}); + +const frame3 = toDataFrame({ + refId: 'B', + name: 'Series B', + fields: [], +}); + +const mockOnChange = jest.fn(); + +const props: Props = { + data: [frame1, frame2, frame3], + onChange: mockOnChange, +}; + +const setup = (testProps?: Partial) => { + const editorProps = { ...props, ...testProps }; + return render(); +}; + +describe('RefIDPicker', () => { + it('Should be able to select frame', async () => { + setup(); + + const select = await screen.findByRole('combobox'); + fireEvent.keyDown(select, { keyCode: 40 }); + + const selectOptions = screen.getAllByLabelText('Select option'); + + expect(selectOptions).toHaveLength(2); + expect(selectOptions[0]).toHaveTextContent('Query: AFrames (2): Series A, Second series'); + expect(selectOptions[1]).toHaveTextContent('Query: BFrames (1): Series B'); + }); +}); diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx index e452fcde5bb..65617bfacb4 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx @@ -1,35 +1,117 @@ -import React, { memo, useMemo, useCallback } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; -import { FieldMatcherID, fieldMatchers, SelectableValue, DataFrame } from '@grafana/data'; +import { DataFrame, getFrameDisplayName, FieldMatcherID, fieldMatchers, SelectableValue } from '@grafana/data'; import { Select } from '../Select/Select'; -import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types'; +import { FieldMatcherUIRegistryItem, MatcherUIProps } from './types'; -/** - * UI to configure "fields by frame refId"-matcher. - * @public - */ -export const FieldsByFrameRefIdMatcher = memo>((props) => { - const { data, options, onChange: onChangeFromProps } = props; - const referenceIDs = useFrameRefIds(data); - const selectOptions = useSelectOptions(referenceIDs); +const recoverRefIdMissing = ( + newRefIds: SelectableValue[], + oldRefIds: SelectableValue[], + previousValue: string | undefined +): SelectableValue | undefined => { + if (!previousValue) { + return; + } + // Previously selected value is missing from the new list. + // Find the value that is in the new list but isn't in the old list + let changedTo = newRefIds.find((refId) => { + return !oldRefIds.some((refId2) => { + return refId === refId2; + }); + }); + if (changedTo) { + // Found the new value, we assume the old value changed to this one, so we'll use it + return changedTo; + } + return; +}; - const onChange = useCallback( - (selection: SelectableValue) => { - if (!selection.value || !referenceIDs.has(selection.value)) { - return; - } - return onChangeFromProps(selection.value); +export interface Props { + value?: string; // refID + data: DataFrame[]; + onChange: (value: string) => void; + placeholder?: string; +} + +// Not exported globally... but used in grafana core +export function RefIDPicker({ value, data, onChange, placeholder }: Props) { + const listOfRefIds = useMemo(() => getListOfQueryRefIds(data), [data]); + + const [priorSelectionState, updatePriorSelectionState] = useState<{ + refIds: SelectableValue[]; + value: string | undefined; + }>({ + refIds: [], + value: undefined, + }); + + const currentValue = useMemo(() => { + return ( + listOfRefIds.find((refId) => refId.value === value) ?? + recoverRefIdMissing(listOfRefIds, priorSelectionState.refIds, priorSelectionState.value) + ); + }, [value, listOfRefIds, priorSelectionState]); + + const onFilterChange = useCallback( + (v: SelectableValue) => { + onChange(v.value!); }, - [referenceIDs, onChangeFromProps] + [onChange] ); - const selectedOption = selectOptions.find((v) => v.value === options); - return + ); +} -FieldsByFrameRefIdMatcher.displayName = 'FieldsByFrameRefIdMatcher'; +function getListOfQueryRefIds(data: DataFrame[]): Array> { + const queries = new Map(); + + for (const frame of data) { + const refId = frame.refId ?? ''; + const frames = queries.get(refId) ?? []; + + if (frames.length === 0) { + queries.set(refId, frames); + } + + frames.push(frame); + } + + const values: Array> = []; + + for (const [refId, frames] of queries.entries()) { + values.push({ + value: refId, + label: `Query: ${refId ?? '(missing refId)'}`, + description: getFramesDescription(frames), + }); + } + + return values; +} + +function getFramesDescription(frames: DataFrame[]): string { + return `Frames (${frames.length}): + ${frames + .slice(0, Math.min(3, frames.length)) + .map((x) => getFrameDisplayName(x)) + .join(', ')} ${frames.length > 3 ? '...' : ''}`; +} /** * Registry item for UI to configure "fields by frame refId"-matcher. @@ -37,32 +119,11 @@ FieldsByFrameRefIdMatcher.displayName = 'FieldsByFrameRefIdMatcher'; */ export const fieldsByFrameRefIdItem: FieldMatcherUIRegistryItem = { id: FieldMatcherID.byFrameRefID, - component: FieldsByFrameRefIdMatcher, + component: (props: MatcherUIProps) => { + return ; + }, matcher: fieldMatchers.get(FieldMatcherID.byFrameRefID), name: 'Fields returned by query', description: 'Set properties for fields from a specific query', optionsToLabel: (options) => options, }; - -const useFrameRefIds = (data: DataFrame[]): Set => { - return useMemo(() => { - const refIds: Set = new Set(); - - for (const frame of data) { - if (frame.refId) { - refIds.add(frame.refId); - } - } - - return refIds; - }, [data]); -}; - -const useSelectOptions = (displayNames: Set): Array> => { - return useMemo(() => { - return Array.from(displayNames).map((n) => ({ - value: n, - label: n, - })); - }, [displayNames]); -}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx index ebbccbab512..7de44c7d3a6 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx @@ -8,7 +8,7 @@ import { StandardEditorContext, StandardEditorsRegistryItem, } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { Field, useStyles2 } from '@grafana/ui'; import { FrameSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor'; interface TransformationFilterProps { @@ -27,14 +27,15 @@ export const TransformationFilter = ({ index, data, config, onChange }: Transfor return (
-
Apply tranformation to
- onChange(index, { ...config, filter })} - /> + + onChange(index, { ...config, filter })} + /> +
); }; diff --git a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx index 98bcac91b8b..26bf727d8b9 100644 --- a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx @@ -1,69 +1,18 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback } from 'react'; -import { - FrameMatcherID, - getFieldDisplayName, - MatcherConfig, - SelectableValue, - StandardEditorProps, -} from '@grafana/data'; -import { Select } from '@grafana/ui'; - -const recoverRefIdMissing = ( - newRefIds: SelectableValue[], - oldRefIds: SelectableValue[], - previousValue: string | undefined -): SelectableValue | undefined => { - if (!previousValue) { - return; - } - // Previously selected value is missing from the new list. - // Find the value that is in the new list but isn't in the old list - let changedTo = newRefIds.find((refId) => { - return !oldRefIds.some((refId2) => { - return refId === refId2; - }); - }); - if (changedTo) { - // Found the new value, we assume the old value changed to this one, so we'll use it - return changedTo; - } - return; -}; +import { FrameMatcherID, MatcherConfig, StandardEditorProps } from '@grafana/data'; +import { RefIDPicker } from '@grafana/ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher'; type Props = StandardEditorProps; -export const FrameSelectionEditor = ({ value, context, onChange, item }: Props) => { - const listOfRefId = useMemo(() => { - return context.data.map((f) => ({ - value: f.refId, - label: `Query: ${f.refId} (size: ${f.length})`, - description: f.fields.map((f) => getFieldDisplayName(f)).join(', '), - })); - }, [context.data]); - - const [priorSelectionState, updatePriorSelectionState] = useState<{ - refIds: SelectableValue[]; - value: string | undefined; - }>({ - refIds: [], - value: undefined, - }); - - const currentValue = useMemo(() => { - return ( - listOfRefId.find((refId) => refId.value === value?.options) ?? - recoverRefIdMissing(listOfRefId, priorSelectionState.refIds, priorSelectionState.value) - ); - }, [value, listOfRefId, priorSelectionState]); - +export const FrameSelectionEditor = ({ value, context, onChange }: Props) => { const onFilterChange = useCallback( - (v: SelectableValue) => { + (v: string) => { onChange( - v?.value + v?.length ? { id: FrameMatcherID.byRefId, - options: v.value, + options: v, } : undefined ); @@ -71,19 +20,7 @@ export const FrameSelectionEditor = ({ value, context, onChange, item }: Props) [onChange] ); - if (listOfRefId !== priorSelectionState.refIds || currentValue?.value !== priorSelectionState.value) { - updatePriorSelectionState({ - refIds: listOfRefId, - value: currentValue?.value, - }); - } return ( - stateManager.onQueryChange(e.currentTarget.value)} + onChange={(e) => stateManager.onQueryChange(e)} onKeyDown={onKeyDown} // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus @@ -55,8 +55,8 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { ? t('search.search-input.include-panels-placeholder', 'Search for dashboards and panels') : t('search.search-input.placeholder', 'Search for dashboards') } + escapeRegex={false} className={styles.searchInput} - suffix={false ? : null} /> {viewActions && ( From 759a05083a4bddb9fb6b786f495445916af36800 Mon Sep 17 00:00:00 2001 From: juanicabanas Date: Mon, 10 Apr 2023 21:02:20 -0300 Subject: [PATCH 017/729] Panels: GeomapPanel edit mode fix (#66222) --- public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx index 26bf727d8b9..8f19c7f2f03 100644 --- a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx @@ -21,6 +21,6 @@ export const FrameSelectionEditor = ({ value, context, onChange }: Props) => { ); return ( - + ); }; From b302cc229712a9864604b24e3e2f05cca1d4a6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 11 Apr 2023 08:53:00 +0200 Subject: [PATCH 018/729] Correlations: Show correct number of variables (#66191) * Show correct number of variables * Remove duplicated test --- public/app/features/explore/utils/links.test.ts | 17 +++++++++++++++++ public/app/features/explore/utils/links.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index b161153a2ca..ef459db08b5 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -671,6 +671,23 @@ describe('explore links utils', () => { const dataLinkRtnVal = getVariableUsageInfo(dataLink, scopedVars).allVariablesDefined; expect(dataLinkRtnVal).toBe(true); }); + + it('returns deduplicated list of variables', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: 'test ${test} ${foo} ${test:raw} $test' }, + }, + }; + const scopedVars = { + testVal: { text: '', value: 'val1' }, + }; + const variables = getVariableUsageInfo(dataLink, scopedVars).variables; + expect(variables).toHaveLength(2); + }); }); }); diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 21ed0166985..818ff1566ca 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash'; import { useCallback } from 'react'; import { @@ -260,9 +261,10 @@ export function getVariableUsageInfo( query: T, scopedVars: ScopedVars ): { variables: VariableInterpolation[]; allVariablesDefined: boolean } { - const variables: VariableInterpolation[] = []; + let variables: VariableInterpolation[] = []; const replaceFn = getTemplateSrv().replace.bind(getTemplateSrv()); replaceFn(getStringsFromObject(query), scopedVars, undefined, variables); + variables = uniqBy(variables, 'variableName'); return { variables: variables, allVariablesDefined: variables.every((variable) => variable.found), From b68be999f7e0da95616e5d9b1ce9d0b7339ba90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 11 Apr 2023 08:53:34 +0200 Subject: [PATCH 019/729] Correlations: Add usage stats about correlations (#66021) * Add usage stats about correlations * Add stats.correlations.count to collected stats * Expose grafana_stat_totals_correlations metric * Organize imports --- pkg/infra/metrics/metrics.go | 10 ++++++++ .../usagestats/statscollector/service.go | 3 +++ .../usagestats/statscollector/service_test.go | 2 ++ .../correlations/correlationstest/fake.go | 23 +++++++++++++++++++ pkg/services/stats/models.go | 1 + pkg/services/stats/statsimpl/stats.go | 1 + pkg/services/stats/statsimpl/stats_test.go | 22 ++++++++++++++++++ 7 files changed, 62 insertions(+) create mode 100644 pkg/services/correlations/correlationstest/fake.go diff --git a/pkg/infra/metrics/metrics.go b/pkg/infra/metrics/metrics.go index 152733c1405..c90c2177c66 100644 --- a/pkg/infra/metrics/metrics.go +++ b/pkg/infra/metrics/metrics.go @@ -201,6 +201,9 @@ var ( // MStatTotalPublicDashboards is a metric total amount of public dashboards MStatTotalPublicDashboards prometheus.Gauge + + // MStatTotalCorrelations is a metric total amount of correlations + MStatTotalCorrelations prometheus.Gauge ) func init() { @@ -592,6 +595,12 @@ func init() { Help: "total amount of public dashboards", Namespace: ExporterName, }) + + MStatTotalCorrelations = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_totals_correlations", + Help: "total amount of correlations", + Namespace: ExporterName, + }) } // SetBuildInformation sets the build information for this binary @@ -705,5 +714,6 @@ func initMetricVars() { MStatTotalPublicDashboards, MPublicDashboardRequestCount, MPublicDashboardDatasourceQuerySuccess, + MStatTotalCorrelations, ) } diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go index 0845468d067..2d0c79e5fc2 100644 --- a/pkg/infra/usagestats/statscollector/service.go +++ b/pkg/infra/usagestats/statscollector/service.go @@ -158,6 +158,7 @@ func (s *Service) collectSystemStats(ctx context.Context) (map[string]interface{ m["stats.data_keys.count"] = statsResult.DataKeys m["stats.active_data_keys.count"] = statsResult.ActiveDataKeys m["stats.public_dashboards.count"] = statsResult.PublicDashboards + m["stats.correlations.count"] = statsResult.Correlations ossEditionCount := 1 enterpriseEditionCount := 0 @@ -314,6 +315,8 @@ func (s *Service) updateTotalStats(ctx context.Context) bool { metrics.MStatTotalPublicDashboards.Set(float64(statsResult.PublicDashboards)) + metrics.MStatTotalCorrelations.Set(float64(statsResult.Correlations)) + dsResult, err := s.statsService.GetDataSourceStats(ctx, &stats.GetDataSourceStatsQuery{}) if err != nil { s.log.Error("Failed to get datasource stats", "error", err) diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go index 98812cf14c8..b7c5299f13e 100644 --- a/pkg/infra/usagestats/statscollector/service_test.go +++ b/pkg/infra/usagestats/statscollector/service_test.go @@ -177,6 +177,7 @@ func TestCollectingUsageStats(t *testing.T) { assert.EqualValues(t, 11, metrics["stats.data_keys.count"]) assert.EqualValues(t, 3, metrics["stats.active_data_keys.count"]) assert.EqualValues(t, 5, metrics["stats.public_dashboards.count"]) + assert.EqualValues(t, 3, metrics["stats.correlations.count"]) assert.InDelta(t, int64(65), metrics["stats.uptime"], 6) } @@ -336,6 +337,7 @@ func mockSystemStats(statsService *statstest.FakeService) { DataKeys: 11, ActiveDataKeys: 3, PublicDashboards: 5, + Correlations: 3, } } diff --git a/pkg/services/correlations/correlationstest/fake.go b/pkg/services/correlations/correlationstest/fake.go new file mode 100644 index 00000000000..8180f35b9e5 --- /dev/null +++ b/pkg/services/correlations/correlationstest/fake.go @@ -0,0 +1,23 @@ +package correlationstest + +import ( + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/correlations" + "github.com/grafana/grafana/pkg/services/datasources" + fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +func New(sqlStore *sqlstore.SQLStore) *correlations.CorrelationsService { + ds := &fakeDatasources.FakeDataSourceService{ + DataSources: []*datasources.DataSource{ + {ID: 1, UID: "graphite", Type: datasources.DS_GRAPHITE}, + }, + } + + correlationsSvc, _ := correlations.ProvideService(sqlStore, routing.NewRouteRegister(), ds, acimpl.ProvideAccessControl(setting.NewCfg()), sqlStore.Bus(), quotatest.New(false, nil), sqlStore.Cfg) + return correlationsSvc +} diff --git a/pkg/services/stats/models.go b/pkg/services/stats/models.go index 66ab9dce1ad..ad8c4519531 100644 --- a/pkg/services/stats/models.go +++ b/pkg/services/stats/models.go @@ -42,6 +42,7 @@ type SystemStats struct { DataKeys int64 ActiveDataKeys int64 PublicDashboards int64 + Correlations int64 } type DataSourceStats struct { diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index dfc8875cd6b..122f6093707 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -73,6 +73,7 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("correlation") + `) AS correlations,`) now := time.Now() activeUserDeadlineDate := now.Add(-activeUserTimeLimit) diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index e458f49105c..f2d1c840efd 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/correlations" + "github.com/grafana/grafana/pkg/services/correlations/correlationstest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -38,6 +40,7 @@ func TestIntegrationStatsDataAccess(t *testing.T) { assert.Equal(t, int64(0), result.LibraryPanels) assert.Equal(t, int64(0), result.LibraryVariables) assert.Equal(t, int64(0), result.APIKeys) + assert.Equal(t, int64(2), result.Correlations) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { @@ -77,6 +80,25 @@ func populateDB(t *testing.T, sqlStore *sqlstore.SQLStore) { orgService, _ := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) userSvc, _ := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) + correlationsSvc := correlationstest.New(sqlStore) + + c := make([]correlations.Correlation, 2) + for i := range c { + cmd := correlations.CreateCorrelationCommand{ + Label: fmt.Sprintf("correlation %v", i), + SourceUID: "graphite", + OrgId: 1, + Config: correlations.CorrelationConfig{ + Field: "field", + Target: map[string]interface{}{}, + Type: correlations.ConfigTypeQuery, + }, + } + correlation, err := correlationsSvc.CreateCorrelation(context.Background(), cmd) + require.NoError(t, err) + c[i] = correlation + } + users := make([]user.User, 3) for i := range users { cmd := user.CreateUserCommand{ From a164b794ce3d66f31bed5ab5fa95f5041d662434 Mon Sep 17 00:00:00 2001 From: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Tue, 11 Apr 2023 09:08:55 +0200 Subject: [PATCH 020/729] Docs: updates to error handling (#65599) * Docs: updates to error handling * ran prettier --- .../create-grafana-managed-rule.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 5585cfb1c58..99557b5ca3c 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -77,18 +77,24 @@ To generate a separate alert for each series, create a multi-dimensional rule. U For more information, see [expressions documentation]({{< relref "/docs/grafana/latest/panels-visualizations/query-transform-data/expression-queries" >}}). -### No data and error handling +### Configure no data and error handling -Configure alerting behavior in the absence of data using information in the following tables. +Configure alerting behavior when your alert rule evaluation returns no data or an error. -| No Data Option | Description | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| No Data | Create a new alert `DatasourceNoData` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | -| Alerting | Set alert rule state to `Alerting`. This option will respect the configured **Evaluate for** pending period. | -| Ok | Set alert rule state to `Normal`. | +**Note:** Alert rules that are configured to fire when an evaluation returns no data or error only fire when the entire duration of the evaluation period has finished. This means that rather than immediately firing when the alert rule condition is breached, the alert rule waits until the time set as the **For** field has finished and then fires, reducing alert noise and allowing for temporary data availability issues. -| Error or timeout option | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Error | Create a new alert `DatasourceError` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | -| Alerting | Set alert rule state to `Alerting`. This option will respect the configured **Evaluate for** pending period. | -| OK | Set alert rule state to `Normal` | +If your alert rule evaluation returns no data, you can set the state on your alert rule to appear as follows: + +| No Data | Description | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| No Data | Creates a new alert `DatasourceNoData` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | +| Alerting | Sets alert rule state to `Alerting`. The alert rule waits until the time set in the **For** field has finished before firing. | +| Ok | Sets alert rule state to `Normal`. | + +If your evaluation returns an error, you can set the state on your alert rule to appear as follows: + +| Error | Description | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Error | Creates an alert instance `DatasourceError` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | +| Alerting | Sets alert rule state to `Alerting`. The alert rule waits until the time set in the **For** field has finished before firing. | +| Ok | Sets alert rule state to `Normal`. | From 0bf2b89eb9fe4a5781b8a27dbe9a10c736500e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 11 Apr 2023 10:05:04 +0200 Subject: [PATCH 021/729] Explore: Align multiple log volumes (#64356) * Align log volumes on x an y axes * Move helper functions to logs/utils * Add tests * Simplify supplementaryQueries.ts * Fix tests * Revert code simplifications To simplify the PR, this can be added in a separate PR * Fix reusing logs volume when limited/non-limited are used * Use more specific property name * Add missing property * Stretch graph to selected range but only if there's data available * Fix unit tests * Fix calculating maximum when bars are stacked * Sort log volumes by data source name * Simplify logic to determine if log volumes can be zoomed in --- packages/grafana-data/src/types/logs.ts | 23 --------- public/app/core/logsModel.test.ts | 41 +++++++++------- .../features/explore/Graph/ExploreGraph.tsx | 8 ++- .../explore/Graph/exploreGraphStyleUtils.ts | 4 +- .../features/explore/LogsVolumePanel.test.tsx | 1 + .../app/features/explore/LogsVolumePanel.tsx | 15 +++--- .../features/explore/LogsVolumePanelList.tsx | 35 ++++++++++--- public/app/features/explore/state/query.ts | 9 +++- public/app/features/logs/utils.test.ts | 49 +++++++++++++++++-- public/app/features/logs/utils.ts | 49 ++++++++++++++++++- 10 files changed, 166 insertions(+), 68 deletions(-) diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index fda6ff0720a..d6508191bd9 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -205,29 +205,6 @@ export type LogsVolumeCustomMetaData = { sourceQuery: DataQuery; }; -export const getLogsVolumeAbsoluteRange = ( - dataFrames: DataFrame[], - defaultRange: AbsoluteTimeRange -): AbsoluteTimeRange => { - return dataFrames[0].meta?.custom?.absoluteRange || defaultRange; -}; - -export const getLogsVolumeDataSourceInfo = (dataFrames: DataFrame[]): { name: string } | null => { - const customMeta = dataFrames[0]?.meta?.custom; - - if (customMeta && customMeta.datasourceName) { - return { - name: customMeta.datasourceName, - }; - } - - return null; -}; - -export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => { - return dataFrames[0]?.meta?.custom?.logsVolumeType === LogsVolumeType.Limited; -}; - /** * Data sources that support supplementary queries in Explore. * This will enable users to see additional data when running original queries. diff --git a/public/app/core/logsModel.test.ts b/public/app/core/logsModel.test.ts index 14daad0b413..0d99b8ea285 100644 --- a/public/app/core/logsModel.test.ts +++ b/public/app/core/logsModel.test.ts @@ -13,6 +13,7 @@ import { LogRowModel, LogsDedupStrategy, LogsMetaKind, + LogsVolumeCustomMetaData, LogsVolumeType, MutableDataFrame, sortDataFrame, @@ -1207,6 +1208,16 @@ describe('logs volume', () => { it('applies correct meta data', async () => { setup(setupMultipleResults); + const logVolumeCustomMeta: LogsVolumeCustomMetaData = { + sourceQuery: { refId: 'A', target: 'volume query 1' } as DataQuery, + datasourceName: 'loki', + logsVolumeType: LogsVolumeType.FullRange, + absoluteRange: { + from: FROM.valueOf(), + to: TO.valueOf(), + }, + }; + await expect(volumeProvider).toEmitValuesWith((received) => { expect(received).toContainEqual({ state: LoadingState.Loading, error: undefined, data: [] }); expect(received).toContainEqual({ @@ -1216,15 +1227,7 @@ describe('logs volume', () => { expect.objectContaining({ fields: expect.anything(), meta: { - custom: { - sourceQuery: { refId: 'A', target: 'volume query 1' }, - datasourceName: 'loki', - logsVolumeType: LogsVolumeType.FullRange, - absoluteRange: { - from: FROM.valueOf(), - to: TO.valueOf(), - }, - }, + custom: logVolumeCustomMeta, }, }), expect.anything(), @@ -1236,6 +1239,16 @@ describe('logs volume', () => { it('applies correct meta data when streaming', async () => { setup(setupMultipleResultsStreaming); + const logVolumeCustomMeta: LogsVolumeCustomMetaData = { + sourceQuery: { refId: 'A', target: 'volume query 1' } as DataQuery, + datasourceName: 'loki', + logsVolumeType: LogsVolumeType.FullRange, + absoluteRange: { + from: FROM.valueOf(), + to: TO.valueOf(), + }, + }; + await expect(volumeProvider).toEmitValuesWith((received) => { expect(received).toContainEqual({ state: LoadingState.Loading, error: undefined, data: [] }); expect(received).toContainEqual({ @@ -1245,15 +1258,7 @@ describe('logs volume', () => { expect.objectContaining({ fields: expect.anything(), meta: { - custom: { - sourceQuery: { refId: 'A', target: 'volume query 1' }, - datasourceName: 'loki', - logsVolumeType: LogsVolumeType.FullRange, - absoluteRange: { - from: FROM.valueOf(), - to: TO.valueOf(), - }, - }, + custom: logVolumeCustomMeta, }, }), expect.anything(), diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index 6e55d7ce570..6ff56904d6e 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -54,6 +54,7 @@ interface Props { onChangeTime: (timeRange: AbsoluteTimeRange) => void; graphStyle: ExploreGraphStyle; anchorToZero?: boolean; + yAxisMaximum?: number; eventBus: EventBus; } @@ -71,6 +72,7 @@ export function ExploreGraph({ graphStyle, tooltipDisplayMode = TooltipDisplayMode.Single, anchorToZero = false, + yAxisMaximum, eventBus, }: Props) { const theme = useTheme2(); @@ -94,6 +96,7 @@ export function ExploreGraph({ const [fieldConfig, setFieldConfig] = useState({ defaults: { min: anchorToZero ? 0 : undefined, + max: yAxisMaximum || undefined, color: { mode: FieldColorModeId.PaletteClassic, }, @@ -106,7 +109,10 @@ export function ExploreGraph({ overrides: [], }); - const styledFieldConfig = useMemo(() => applyGraphStyle(fieldConfig, graphStyle), [fieldConfig, graphStyle]); + const styledFieldConfig = useMemo( + () => applyGraphStyle(fieldConfig, graphStyle, yAxisMaximum), + [fieldConfig, graphStyle, yAxisMaximum] + ); const dataWithConfig = useMemo(() => { return applyFieldOverrides({ diff --git a/public/app/features/explore/Graph/exploreGraphStyleUtils.ts b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts index f48c746254e..1bbf6f7ad7a 100644 --- a/public/app/features/explore/Graph/exploreGraphStyleUtils.ts +++ b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts @@ -6,12 +6,14 @@ import { ExploreGraphStyle } from 'app/types'; export type FieldConfig = FieldConfigSource; -export function applyGraphStyle(config: FieldConfig, style: ExploreGraphStyle): FieldConfig { +export function applyGraphStyle(config: FieldConfig, style: ExploreGraphStyle, maximum?: number): FieldConfig { return produce(config, (draft) => { if (draft.defaults.custom === undefined) { draft.defaults.custom = {}; } + draft.defaults.max = maximum; + const { custom } = draft.defaults; if (custom.stacking === undefined) { diff --git a/public/app/features/explore/LogsVolumePanel.test.tsx b/public/app/features/explore/LogsVolumePanel.test.tsx index 54ebf8962c3..683600d1f56 100644 --- a/public/app/features/explore/LogsVolumePanel.test.tsx +++ b/public/app/features/explore/LogsVolumePanel.test.tsx @@ -24,6 +24,7 @@ function renderPanel(logsVolumeData?: DataQueryResponse) { onLoadLogsVolume={() => {}} onHiddenSeriesChanged={() => null} eventBus={new EventBusSrv()} + allLogsVolumeMaximum={20} /> ); } diff --git a/public/app/features/explore/LogsVolumePanel.tsx b/public/app/features/explore/LogsVolumePanel.tsx index 278b827b68a..a39b18cf52b 100644 --- a/public/app/features/explore/LogsVolumePanel.tsx +++ b/public/app/features/explore/LogsVolumePanel.tsx @@ -9,17 +9,17 @@ import { SplitOpen, TimeZone, EventBus, - isLogsVolumeLimited, - getLogsVolumeAbsoluteRange, GrafanaTheme2, - getLogsVolumeDataSourceInfo, } from '@grafana/data'; import { Icon, Tooltip, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; +import { getLogsVolumeDataSourceInfo, isLogsVolumeLimited } from '../logs/utils'; + import { ExploreGraph } from './Graph/ExploreGraph'; type Props = { logsVolumeData: DataQueryResponse | undefined; + allLogsVolumeMaximum: number; absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; splitOpen: SplitOpen; @@ -31,7 +31,7 @@ type Props = { }; export function LogsVolumePanel(props: Props) { - const { width, timeZone, splitOpen, onUpdateTimeRange, onHiddenSeriesChanged } = props; + const { width, timeZone, splitOpen, onUpdateTimeRange, onHiddenSeriesChanged, allLogsVolumeMaximum } = props; const theme = useTheme2(); const styles = useStyles2(getStyles); const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); @@ -55,10 +55,6 @@ export function LogsVolumePanel(props: Props) { .join('. '); } - const range = isLogsVolumeLimited(logsVolumeData.data) - ? getLogsVolumeAbsoluteRange(logsVolumeData.data, props.absoluteRange) - : props.absoluteRange; - let LogsVolumePanelContent; if (logsVolumeData?.data) { @@ -70,13 +66,14 @@ export function LogsVolumePanel(props: Props) { data={logsVolumeData.data} height={height} width={width - spacing * 2} - absoluteRange={range} + absoluteRange={props.absoluteRange} onChangeTime={onUpdateTimeRange} timeZone={timeZone} splitOpenFn={splitOpen} tooltipDisplayMode={TooltipDisplayMode.Multi} onHiddenSeriesChanged={onHiddenSeriesChanged} anchorToZero + yAxisMaximum={allLogsVolumeMaximum} eventBus={props.eventBus} /> ); diff --git a/public/app/features/explore/LogsVolumePanelList.tsx b/public/app/features/explore/LogsVolumePanelList.tsx index f530336822d..490eaaf81a4 100644 --- a/public/app/features/explore/LogsVolumePanelList.tsx +++ b/public/app/features/explore/LogsVolumePanelList.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { groupBy, mapValues } from 'lodash'; +import { flatten, groupBy, mapValues, sortBy } from 'lodash'; import React, { useMemo } from 'react'; import { @@ -8,14 +8,13 @@ import { DataQueryResponse, EventBus, GrafanaTheme2, - isLogsVolumeLimited, LoadingState, SplitOpen, TimeZone, } from '@grafana/data'; import { Button, InlineField, useStyles2 } from '@grafana/ui'; -import { mergeLogsVolumeDataFrames } from '../logs/utils'; +import { mergeLogsVolumeDataFrames, isLogsVolumeLimited, getLogsVolumeMaximumRange } from '../logs/utils'; import { LogsVolumePanel } from './LogsVolumePanel'; import { SupplementaryResultError } from './SupplementaryResultError'; @@ -46,11 +45,25 @@ export const LogsVolumePanelList = ({ timeZone, onClose, }: Props) => { - const logVolumes: Record = useMemo(() => { - const grouped = groupBy(logsVolumeData?.data || [], 'meta.custom.datasourceName'); - return mapValues(grouped, (value) => { - return mergeLogsVolumeDataFrames(value); + const { + logVolumes, + maximumValue: allLogsVolumeMaximumValue, + maximumRange: allLogsVolumeMaximumRange, + } = useMemo(() => { + let maximumValue = -Infinity; + const sorted = sortBy(logsVolumeData?.data || [], 'meta.custom.datasourceName'); + const grouped = groupBy(sorted, 'meta.custom.datasourceName'); + const logVolumes = mapValues(grouped, (value) => { + const mergedData = mergeLogsVolumeDataFrames(value); + maximumValue = Math.max(maximumValue, mergedData.maximum); + return mergedData.dataFrames; }); + const maximumRange = getLogsVolumeMaximumRange(flatten(Object.values(logVolumes))); + return { + maximumValue, + maximumRange, + logVolumes, + }; }, [logsVolumeData]); const styles = useStyles2(getStyles); @@ -64,6 +77,11 @@ export const LogsVolumePanelList = ({ const timeoutError = isTimeoutErrorResponse(logsVolumeData); + const visibleRange = { + from: Math.max(absoluteRange.from, allLogsVolumeMaximumRange.from), + to: Math.min(absoluteRange.to, allLogsVolumeMaximumRange.to), + }; + if (logsVolumeData?.state === LoadingState.Loading) { return Loading...; } else if (timeoutError) { @@ -87,7 +105,8 @@ export const LogsVolumePanelList = ({ return ( { + // If log volume is based on returned log lines (i.e. LogsVolumeType.Limited), + // zooming in may return different results, so we don't want to reuse the data + return data.meta?.custom?.logsVolumeType === LogsVolumeType.FullRange; + }); + const allQueriesAreTheSame = deepEqual(newQueriesByRefId, existingDataByRefId); const allResultsHaveWiderRange = supplementaryQueryData.data.every((data: DataFrame) => { @@ -707,7 +714,7 @@ function canReuseSupplementaryQueryData( return hasWiderRange; }); - return allQueriesAreTheSame && allResultsHaveWiderRange; + return allSupportZoomingIn && allQueriesAreTheSame && allResultsHaveWiderRange; } /** diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index 67f33b56547..385f3e20b0f 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -1,6 +1,6 @@ import { + AbsoluteTimeRange, ArrayVector, - DataFrame, FieldType, Labels, LogLevel, @@ -8,6 +8,7 @@ import { LogsModel, LogsSortOrder, MutableDataFrame, + DataFrame, } from '@grafana/data'; import { @@ -16,6 +17,7 @@ import { checkLogsError, getLogLevel, getLogLevelFromKey, + getLogsVolumeMaximumRange, logRowsToReadableJson, mergeLogsVolumeDataFrames, sortLogsResult, @@ -296,13 +298,15 @@ describe('mergeLogsVolumeDataFrames', () => { const debugVolume1 = mockLogVolume('debug', [2, 3], [2, 3]); const debugVolume2 = mockLogVolume('debug', [1, 5], [1, 0]); - // error 1: - - - - - 1 - // error 2: 1 - - - - 1 - // total: 1 - - - - 2 + // error 1: 1 - - - - 1 + // error 2: 1 - - - - - + // total: 2 - - - - 1 const errorVolume1 = mockLogVolume('error', [1, 6], [1, 1]); const errorVolume2 = mockLogVolume('error', [1], [1]); - const merged = mergeLogsVolumeDataFrames([ + // all totals: 6 5 4 - 0 2 + + const { dataFrames: merged, maximum } = mergeLogsVolumeDataFrames([ infoVolume1, infoVolume2, debugVolume1, @@ -365,5 +369,40 @@ describe('mergeLogsVolumeDataFrames', () => { ], }, ]); + expect(maximum).toBe(6); + }); +}); + +describe('getLogsVolumeDimensions', () => { + function mockLogVolumeDataFrame(values: number[], absoluteRange: AbsoluteTimeRange) { + return new MutableDataFrame({ + meta: { + custom: { + absoluteRange, + }, + }, + fields: [ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([]), + }, + { + name: 'value', + type: FieldType.number, + values: new ArrayVector(values), + }, + ], + }); + } + + it('calculates the maximum value and range of all log volumes', () => { + const maximumRange = getLogsVolumeMaximumRange([ + mockLogVolumeDataFrame([], { from: 5, to: 20 }), + mockLogVolumeDataFrame([], { from: 10, to: 25 }), + mockLogVolumeDataFrame([], { from: 7, to: 23 }), + ]); + + expect(maximumRange).toEqual({ from: 5, to: 25 }); }); }); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 9b6e560a416..9e164c18e22 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -12,6 +12,7 @@ import { FieldType, MutableDataFrame, QueryResultMeta, + LogsVolumeType, } from '@grafana/data'; import { getDataframeFields } from './components/logParser'; @@ -163,12 +164,37 @@ export function logRowsToReadableJson(logs: LogRowModel[]) { }); } -export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): DataFrame[] => { +export const getLogsVolumeMaximumRange = (dataFrames: DataFrame[]) => { + let widestRange = { from: Infinity, to: -Infinity }; + + dataFrames.forEach((dataFrame: DataFrame) => { + const meta = dataFrame.meta?.custom || {}; + if (meta.absoluteRange?.from && meta.absoluteRange?.to) { + widestRange = { + from: Math.min(widestRange.from, meta.absoluteRange.from), + to: Math.max(widestRange.to, meta.absoluteRange.to), + }; + } + }); + + return widestRange; +}; + +/** + * Merge data frames by level and calculate maximum total value for all levels together + */ +export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): { dataFrames: DataFrame[]; maximum: number } => { if (dataFrames.length === 0) { throw new Error('Cannot aggregate data frames: there must be at least one data frame to aggregate'); } + // aggregate by level (to produce data frames) const aggregated: Record> = {}; + + // aggregate totals to align Y axis when multiple log volumes are shown + const totals: Record = {}; + let maximumValue = -Infinity; + const configs: Record< string, { meta?: QueryResultMeta; valueFieldConfig: FieldConfig; timeFieldConfig: FieldConfig } @@ -201,6 +227,9 @@ export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): DataFrame[] const value: number = valueField.values.get(pointIndex); aggregated[level] ??= {}; aggregated[level][time] = (aggregated[level][time] || 0) + value; + + totals[time] = (totals[time] || 0) + value; + maximumValue = Math.max(totals[time], maximumValue); } }); @@ -225,5 +254,21 @@ export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): DataFrame[] results.push(levelDataFrame); }); - return results; + return { dataFrames: results, maximum: maximumValue }; +}; + +export const getLogsVolumeDataSourceInfo = (dataFrames: DataFrame[]): { name: string } | null => { + const customMeta = dataFrames[0]?.meta?.custom; + + if (customMeta && customMeta.datasourceName) { + return { + name: customMeta.datasourceName, + }; + } + + return null; +}; + +export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => { + return dataFrames[0]?.meta?.custom?.logsVolumeType === LogsVolumeType.Limited; }; From 1d0e74f9988ef4dac3018b482d1062c7d2c50f34 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 11 Apr 2023 09:08:46 +0100 Subject: [PATCH 022/729] PanelHeaderMenuTrigger: Store `clickCoordinates` in a ref instead of state (#65601) rewrite panelheadermenutrigger to use ref instead of state --- .../PanelHeader/PanelHeaderMenuTrigger.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx index 7b9bb06df6d..6d12cb3d1f5 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -1,4 +1,4 @@ -import React, { HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react'; +import React, { HTMLAttributes, MouseEvent, ReactElement, useCallback, useRef, useState } from 'react'; import { CartesianCoords2D } from '@grafana/data'; @@ -12,26 +12,23 @@ interface Props extends Omit, 'children'> { } export function PanelHeaderMenuTrigger({ children, ...divProps }: Props) { - const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); + const clickCoordinates = useRef({ x: 0, y: 0 }); const [panelMenuOpen, setPanelMenuOpen] = useState(false); const onMenuToggle = useCallback( (event: MouseEvent) => { - if (!isClick(clickCoordinates, eventToClickCoordinates(event))) { + if (!isClick(clickCoordinates.current, eventToClickCoordinates(event))) { return; } setPanelMenuOpen(!panelMenuOpen); }, - [clickCoordinates, panelMenuOpen, setPanelMenuOpen] + [panelMenuOpen, setPanelMenuOpen] ); - const onMouseDown = useCallback( - (event: MouseEvent) => { - setClickCoordinates(eventToClickCoordinates(event)); - }, - [setClickCoordinates] - ); + const onMouseDown = useCallback((event: MouseEvent) => { + clickCoordinates.current = eventToClickCoordinates(event); + }, []); return (
From 2e2c98953097b9ca5f3075028e6ec211baf82c9c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 11 Apr 2023 09:10:32 +0100 Subject: [PATCH 023/729] Dashboard: rewrite `useDashboardSave` to not use `useEffect` (#65602) rewrite useDashboardSave to not use useEffect --- .../SaveDashboard/useDashboardSave.tsx | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index b636f8b50e5..391ac86c90f 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -1,5 +1,4 @@ -import { useEffect } from 'react'; -import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; import { locationService, reportInteraction } from '@grafana/runtime'; @@ -27,54 +26,55 @@ const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dash }; export const useDashboardSave = (dashboard: DashboardModel, isCopy = false) => { - const [state, onDashboardSave] = useAsyncFn( - async (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => - await saveDashboard(clone, options, dashboard), - [] - ); const dispatch = useDispatch(); - const notifyApp = useAppNotification(); - useEffect(() => { - if (state.error && !state.loading) { - notifyApp.error(state.error.message ?? 'Error saving dashboard'); - } - if (state.value) { - dashboard.version = state.value.version; - dashboard.clearUnsavedChanges(); + const [state, onDashboardSave] = useAsyncFn( + async (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { + try { + const result = await saveDashboard(clone, options, dashboard); + dashboard.version = result.version; + dashboard.clearUnsavedChanges(); - // important that these happen before location redirect below - appEvents.publish(new DashboardSavedEvent()); - notifyApp.success('Dashboard saved'); - if (isCopy) { - reportInteraction('grafana_dashboard_copied', { - name: dashboard.title, - url: state.value.url, - }); - } else { - reportInteraction(`grafana_dashboard_${dashboard.id ? 'saved' : 'created'}`, { - name: dashboard.title, - url: state.value.url, - }); - } + // important that these happen before location redirect below + appEvents.publish(new DashboardSavedEvent()); + notifyApp.success('Dashboard saved'); + if (isCopy) { + reportInteraction('grafana_dashboard_copied', { + name: dashboard.title, + url: result.url, + }); + } else { + reportInteraction(`grafana_dashboard_${dashboard.id ? 'saved' : 'created'}`, { + name: dashboard.title, + url: result.url, + }); + } - const currentPath = locationService.getLocation().pathname; - const newUrl = locationUtil.stripBaseFromUrl(state.value.url); + const currentPath = locationService.getLocation().pathname; + const newUrl = locationUtil.stripBaseFromUrl(result.url); - if (newUrl !== currentPath) { - setTimeout(() => locationService.replace(newUrl)); + if (newUrl !== currentPath) { + setTimeout(() => locationService.replace(newUrl)); + } + if (dashboard.meta.isStarred) { + dispatch( + updateDashboardName({ + id: dashboard.uid, + title: dashboard.title, + url: newUrl, + }) + ); + } + return result; + } catch (error) { + if (error instanceof Error) { + notifyApp.error(error.message ?? 'Error saving dashboard'); + } + throw error; } - if (dashboard.meta.isStarred) { - dispatch( - updateDashboardName({ - id: dashboard.uid, - title: dashboard.title, - url: newUrl, - }) - ); - } - } - }, [dashboard, isCopy, state, notifyApp, dispatch]); + }, + [dispatch, notifyApp] + ); return { state, onDashboardSave }; }; From dec3361331b9fcb6a4db3454c231a465e8e24e0f Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Tue, 11 Apr 2023 09:32:54 +0100 Subject: [PATCH 024/729] Remove "Open source" label from all "RBAC" pages (#66129) Signed-off-by: Jack Baldry --- .../roles-and-permissions/access-control/_index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/sources/administration/roles-and-permissions/access-control/_index.md b/docs/sources/administration/roles-and-permissions/access-control/_index.md index c4e96848c3c..d95e7aa214d 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/_index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/_index.md @@ -3,6 +3,11 @@ aliases: - ../../enterprise/access-control/ - ../../enterprise/access-control/about-rbac/ - ../../enterprise/access-control/roles/ +cascade: + labels: + products: + - cloud + - enterprise description: Role-based access control (RBAC) provides a standardized way of granting, changing, and revoking access so that users can view and modify Grafana resources, such as users and reports. From a5499bbf702cc45a461987e25628b5dc798c0a11 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Tue, 11 Apr 2023 09:34:17 +0100 Subject: [PATCH 025/729] Remove "Open source" label from "Recorded queries" page (#66127) Signed-off-by: Jack Baldry --- docs/sources/administration/recorded-queries/index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sources/administration/recorded-queries/index.md b/docs/sources/administration/recorded-queries/index.md index cc380b234ca..81475ae48c3 100644 --- a/docs/sources/administration/recorded-queries/index.md +++ b/docs/sources/administration/recorded-queries/index.md @@ -7,6 +7,10 @@ keywords: - query - queries - recorded +labels: + products: + - cloud + - enterprise title: Recorded queries weight: 300 --- From 1261345b81ea12346ed405bc30429564a06e2b99 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 11 Apr 2023 10:51:54 +0100 Subject: [PATCH 026/729] Chore: Upgrade to react 18 (#64428) * update react 18 related deps * fix some types * make sure we're on react-router-dom >= 5.3.3 * Use new root API * Remove StrictMode for now - react 18 double rendering causes issues * fix + ignore some @grafana/ui types * fix some more types * use renderHook from @testing-library/react in almost all cases * fix storybook types * rewrite useDashboardSave to not use useEffect * make props optional * only render if props are provided * add correct type for useCallback * make resourcepicker tests more robust * fix ModalManager rendering * fix some more unit tests * store the click coordinates in a ref as setState is NOT synchronous * fix remaining e2e tests * rewrite dashboardpage tests to avoid act warnings * undo lint ignores * fix ExpanderCell types * set SymbolCell type correctly * fix QueryAndExpressionsStep * looks like the types were actually wrong instead :D * undo this for now... * remove spinner waits * more robust tests * rewrite errorboundary test to not explicitly count the number of renders * make urlParam expect async * increase timeout in waitFor * revert ExplorePage test changes * Update public/app/features/dashboard/containers/DashboardPage.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard/containers/PublicDashboardPage.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard/containers/PublicDashboardPage.test.tsx Co-authored-by: Alex Khomenko * Update public/app/features/dashboard/containers/PublicDashboardPage.test.tsx Co-authored-by: Alex Khomenko * skip fakeTimer test, ignore table types for now + other review comments * update package peerDeps * small tweak to resourcepicker test * update lockfile... * increase timeout in sharepublicdashboard tests * ensure ExplorePaneContainer passes correct queries to initializeExplore * fix LokiContextUI test * fix unit tests * make importDashboard flow more consistent * wait for dashboard name before continuing * more test fixes * readd dashboard name to variable e2e tests * wait for switches to be enabled before clicking * fix modal rendering * don't use @testing-library/dom directly * quick fix for rendering of panels in firefox * make PromQueryField test more robust * don't wait for chartData - in react 18 this can happen before the wait code even gets executed --------- Co-authored-by: kay delaney Co-authored-by: Alex Khomenko --- .github/renovate.json5 | 10 - ...ook-addon-docs-npm-6.5.16-56ecbd77e7.patch | 14 + ...act-split-pane-npm-0.1.92-93dbf51dff.patch | 12 + ...g_a_row_with_a_non_repeating_panel.spec.ts | 2 + .../new-constant-variable.spec.ts | 4 +- .../new-custom-variable.spec.ts | 3 + .../new-datasource-variable.spec.ts | 2 + .../new-interval-variable.spec.ts | 2 + .../new-query-variable.spec.ts | 4 + .../new-text-box-variable.spec.ts | 2 + .../trace-view-scrolling.spec.ts | 4 +- package.json | 24 +- packages/grafana-data/package.json | 19 +- .../grafana-e2e/src/flows/configurePanel.ts | 3 - .../grafana-e2e/src/flows/importDashboard.ts | 7 +- packages/grafana-runtime/package.json | 17 +- .../usePluginInteractionReporter.test.tsx | 2 +- packages/grafana-ui/package.json | 23 +- .../src/components/Dropdown/Dropdown.tsx | 2 +- .../ErrorBoundary/ErrorBoundary.test.tsx | 6 +- .../InteractiveTable/ExpanderCell.tsx | 2 +- .../src/components/Logs/LogRowContext.tsx | 3 +- .../src/components/Menu/MenuItem.test.tsx | 3 +- .../src/components/Menu/hooks.test.tsx | 4 +- .../src/components/Segment/Segment.tsx | 3 +- .../src/components/Segment/SegmentAsync.tsx | 3 +- .../src/components/Slider/HandleTooltip.tsx | 3 +- .../src/components/Slider/RangeSlider.tsx | 2 +- .../grafana-ui/src/components/Table/utils.ts | 4 + .../src/themes/ThemeContext.test.tsx | 3 +- packages/grafana-ui/src/utils/reactUtils.ts | 4 +- .../internal/input-datasource/package.json | 8 +- public/app/AppWrapper.tsx | 70 ++-- public/app/angular/services/ng_react.ts | 29 +- public/app/app.ts | 8 +- public/app/core/services/ModalManager.ts | 9 +- .../alerting/NotificationsListPage.tsx | 2 +- .../features/alerting/TestRuleResult.test.tsx | 10 +- .../alerting/unified/Receivers.test.tsx | 27 +- .../notification-policies/Modals.tsx | 6 +- .../hooks/useAlertManagerSourceName.test.tsx | 2 +- .../hooks/useExternalAMSelector.test.tsx | 93 ++--- .../unified/hooks/useIsRuleEditable.test.tsx | 2 +- .../AnnotationsSettings.test.tsx | 3 +- .../DashboardSettings/LinksSettings.test.tsx | 3 +- .../VersionsSettings.test.tsx | 3 +- .../SharePublicDashboard.test.tsx | 33 +- .../containers/DashboardPage.test.tsx | 338 ++++++++-------- .../containers/PublicDashboardPage.test.tsx | 280 ++++++-------- .../dashboard/dashgrid/DashboardGrid.test.tsx | 3 +- .../dashboard/dashgrid/DashboardPanel.tsx | 4 +- .../dashboard/dashgrid/LazyLoader.tsx | 4 +- .../components/DataSourceTestingStatus.tsx | 2 +- .../features/explore/ExplorePaneContainer.tsx | 4 +- .../TraceView/useChildrenState.test.ts | 2 +- .../explore/TraceView/useDetailState.test.ts | 2 +- .../TraceView/useHoverIndentGuide.test.ts | 2 +- .../explore/TraceView/useSearch.test.ts | 2 +- .../explore/TraceView/useViewRange.test.ts | 2 +- .../features/explore/spec/helper/setup.tsx | 3 +- public/app/features/explore/state/query.ts | 3 +- .../LibraryPanelsSearch.test.tsx | 3 +- .../logs/components/LogRowContext.tsx | 6 +- .../features/playlist/PlaylistForm.test.tsx | 3 +- .../profile/UserProfileEditPage.test.tsx | 3 +- .../search/page/components/columns.tsx | 8 +- .../MetricsQueryEditor/dataHooks.test.ts | 70 ++-- .../components/QueryEditor/QueryEditor.tsx | 2 +- .../ResourcePicker/ResourcePicker.test.tsx | 26 +- .../VariableEditor/VariableEditor.tsx | 4 +- .../azuremonitor/utils/useAsyncState.test.ts | 25 +- .../azuremonitor/utils/useLastError.test.ts | 2 +- .../datasource/cloudwatch/hooks.test.ts | 82 ++-- .../useMigratedMetricsQuery.test.ts | 2 +- .../elasticsearch/hooks/useFields.test.tsx | 2 +- .../elasticsearch/hooks/useNextId.test.tsx | 2 +- .../VisualInfluxQLEditor/Editor.test.tsx | 10 +- .../components/useShadowedState.test.ts | 2 +- .../influxdb/components/useUniqueId.test.ts | 2 +- .../loki/components/LokiContextUi.test.tsx | 5 +- .../parca/QueryEditor/QueryEditor.test.tsx | 3 +- .../phlare/QueryEditor/QueryEditor.test.tsx | 3 +- .../components/PromQueryField.test.tsx | 8 +- .../querybuilder/shared/hooks/useFlag.test.ts | 2 +- .../datasource/zipkin/QueryField.test.tsx | 32 +- .../components/FlameGraph/FlameGraph.test.tsx | 3 +- public/app/plugins/panel/graph/graph.ts | 13 +- .../plugins/panel/nodeGraph/layout.test.ts | 2 +- yarn.lock | 361 +++++++++--------- 89 files changed, 921 insertions(+), 920 deletions(-) create mode 100644 .yarn/patches/@storybook-addon-docs-npm-6.5.16-56ecbd77e7.patch create mode 100644 .yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch diff --git a/.github/renovate.json5 b/.github/renovate.json5 index a652df0a8ad..83033b38060 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -22,16 +22,6 @@ "@sentry/browser", "@sentry/types", "@sentry/utils", - - // dep updates blocked by React 18 - "@testing-library/dom", - "@testing-library/react", - "@types/react", - "@types/react-dom", - "@types/react-test-renderer", - "react", - "react-dom", - "react-test-renderer" ], "includePaths": ["package.json", "packages/**"], "ignorePaths": ["packages/grafana-toolkit/package.json", "emails/**", "plugins-bundled/**", "**/mocks/**"], diff --git a/.yarn/patches/@storybook-addon-docs-npm-6.5.16-56ecbd77e7.patch b/.yarn/patches/@storybook-addon-docs-npm-6.5.16-56ecbd77e7.patch new file mode 100644 index 00000000000..5fc706f7d5d --- /dev/null +++ b/.yarn/patches/@storybook-addon-docs-npm-6.5.16-56ecbd77e7.patch @@ -0,0 +1,14 @@ +diff --git a/dist/ts3.9/blocks/DocsContainer.d.ts b/dist/ts3.9/blocks/DocsContainer.d.ts +index be330e44bebb02eaf2c92d365d4e7dc1da452465..6c8b1d42bea2e184456e2757eb2ee20076ba43b3 100644 +--- a/dist/ts3.9/blocks/DocsContainer.d.ts ++++ b/dist/ts3.9/blocks/DocsContainer.d.ts +@@ -1,7 +1,8 @@ +-import { FunctionComponent } from 'react'; ++import { FunctionComponent, ReactNode } from 'react'; + import { AnyFramework } from '@storybook/csf'; + import { DocsContextProps } from './DocsContext'; + export interface DocsContainerProps { + context: DocsContextProps; ++ children?: ReactNode; + } + export declare const DocsContainer: FunctionComponent; diff --git a/.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch b/.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch new file mode 100644 index 00000000000..1ea29362cf1 --- /dev/null +++ b/.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch @@ -0,0 +1,12 @@ +diff --git a/index.d.ts b/index.d.ts +index d116f54d6da12d24b48e24ff3636c9066059aa58..93290945d8b1818cab893d6466179b33869a47b9 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -25,6 +25,7 @@ export type SplitPaneProps = { + pane2Style?: React.CSSProperties; + resizerClassName?: string; + step?: number; ++ children?: React.ReactNode; + }; + + export type SplitPaneState = { diff --git a/e2e/dashboards-suite/Repeating_a_row_with_a_non_repeating_panel.spec.ts b/e2e/dashboards-suite/Repeating_a_row_with_a_non_repeating_panel.spec.ts index c47eb7fd096..0b44c94cf85 100644 --- a/e2e/dashboards-suite/Repeating_a_row_with_a_non_repeating_panel.spec.ts +++ b/e2e/dashboards-suite/Repeating_a_row_with_a_non_repeating_panel.spec.ts @@ -1,5 +1,6 @@ import { e2e } from '@grafana/e2e'; const PAGE_UNDER_TEST = 'k3PEoCpnk/repeating-a-row-with-a-non-repeating-panel-and-horizontal-repeating-panel'; +const DASHBOARD_NAME = 'Repeating a row with a non-repeating panel and horizontal repeating panel'; describe('Repeating a row with repeated panels and a non-repeating panel', () => { beforeEach(() => { @@ -8,6 +9,7 @@ describe('Repeating a row with repeated panels and a non-repeating panel', () => it('should be able to collapse and expand a repeated row without losing panels', () => { e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); const panelsToCheck = [ 'Row 2 non-repeating panel', diff --git a/e2e/dashboards-suite/new-constant-variable.spec.ts b/e2e/dashboards-suite/new-constant-variable.spec.ts index bdb990828ed..c595e49c692 100644 --- a/e2e/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e/dashboards-suite/new-constant-variable.spec.ts @@ -2,11 +2,13 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; describe('Variables - Constant', () => { it('can add a new constant variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "Constant" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); @@ -15,8 +17,8 @@ describe('Variables - Constant', () => { e2e().get('input').type('Constant{enter}'); }); e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); - e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2().type('pesto').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'pesto'); diff --git a/e2e/dashboards-suite/new-custom-variable.spec.ts b/e2e/dashboards-suite/new-custom-variable.spec.ts index 1ed710f11d3..79b8a139a28 100644 --- a/e2e/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e/dashboards-suite/new-custom-variable.spec.ts @@ -2,6 +2,7 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; function fillInCustomVariable(name: string, label: string, value: string) { e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { @@ -23,6 +24,7 @@ describe('Variables - Custom', () => { it('can add a custom template variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "Custom" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); @@ -50,6 +52,7 @@ describe('Variables - Custom', () => { it('can add a custom template variable with labels', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "Custom" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); diff --git a/e2e/dashboards-suite/new-datasource-variable.spec.ts b/e2e/dashboards-suite/new-datasource-variable.spec.ts index 8d9bdfb4ea3..27aaa3c15fd 100644 --- a/e2e/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e/dashboards-suite/new-datasource-variable.spec.ts @@ -2,11 +2,13 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; describe('Variables - Datasource', () => { it('can add a new datasource variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "Datasource" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); diff --git a/e2e/dashboards-suite/new-interval-variable.spec.ts b/e2e/dashboards-suite/new-interval-variable.spec.ts index c55fb7b0796..6e4eeda55af 100644 --- a/e2e/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e/dashboards-suite/new-interval-variable.spec.ts @@ -2,6 +2,7 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; function assertPreviewValues(expectedValues: string[]) { for (const expected of expectedValues) { @@ -14,6 +15,7 @@ describe('Variables - Interval', () => { it('can add a new interval variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "Interval" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); diff --git a/e2e/dashboards-suite/new-query-variable.spec.ts b/e2e/dashboards-suite/new-query-variable.spec.ts index 2dfc095d9bc..307f3da7b6c 100644 --- a/e2e/dashboards-suite/new-query-variable.spec.ts +++ b/e2e/dashboards-suite/new-query-variable.spec.ts @@ -2,11 +2,13 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; +const DASHBOARD_NAME = 'Templating - Nested Template Variables'; describe('Variables - Query - Add variable', () => { it('query variable should be default and default fields should be correct', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); @@ -77,6 +79,7 @@ describe('Variables - Query - Add variable', () => { it('adding a single value query variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); @@ -132,6 +135,7 @@ describe('Variables - Query - Add variable', () => { it('adding a multi value query variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); diff --git a/e2e/dashboards-suite/new-text-box-variable.spec.ts b/e2e/dashboards-suite/new-text-box-variable.spec.ts index f07385729d2..9bd24f16157 100644 --- a/e2e/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e/dashboards-suite/new-text-box-variable.spec.ts @@ -2,11 +2,13 @@ import { e2e } from '@grafana/e2e'; import { GrafanaBootConfig } from '@grafana/runtime'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; describe('Variables - Text box', () => { it('can add a new text box variable', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); // Create a new "text box" variable e2e.components.CallToActionCard.buttonV2('Add variable').click(); diff --git a/e2e/various-suite/trace-view-scrolling.spec.ts b/e2e/various-suite/trace-view-scrolling.spec.ts index 3342281afe5..5fd3cfdae0f 100644 --- a/e2e/various-suite/trace-view-scrolling.spec.ts +++ b/e2e/various-suite/trace-view-scrolling.spec.ts @@ -29,7 +29,9 @@ describe('Trace view', () => { e2e.pages.Explore.General.scrollView().children('.scrollbar-view').scrollTo('center'); // After scrolling we should load more spans - e2e.components.TraceViewer.spanBar().its('length').should('be.gt', oldLength); + e2e.components.TraceViewer.spanBar().should(($span) => { + expect($span.length).to.be.gt(oldLength); + }); }); }); }); diff --git a/package.json b/package.json index 1e8cf53df28..d266f25c225 100644 --- a/package.json +++ b/package.json @@ -114,10 +114,9 @@ "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@swc/core": "1.3.38", "@swc/helpers": "0.4.14", - "@testing-library/dom": "8.20.0", + "@testing-library/dom": "9.0.1", "@testing-library/jest-dom": "5.16.5", - "@testing-library/react": "12.1.4", - "@testing-library/react-hooks": "8.0.1", + "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "@types/angular": "1.8.4", "@types/angular-route": "1.7.2", @@ -146,15 +145,15 @@ "@types/papaparse": "5.3.7", "@types/pluralize": "^0.0.29", "@types/prismjs": "1.26.0", - "@types/react": "17.0.42", + "@types/react": "18.0.28", "@types/react-beautiful-dnd": "13.1.3", - "@types/react-dom": "17.0.14", + "@types/react-dom": "18.0.11", "@types/react-grid-layout": "1.3.2", "@types/react-highlight-words": "0.16.4", "@types/react-redux": "7.1.25", "@types/react-router-dom": "5.3.3", "@types/react-table": "7.7.14", - "@types/react-test-renderer": "17.0.1", + "@types/react-test-renderer": "18.0.0", "@types/react-transition-group": "4.4.5", "@types/react-virtualized-auto-sizer": "1.0.1", "@types/react-window": "1.8.5", @@ -226,7 +225,7 @@ "react-refresh": "0.14.0", "react-select-event": "5.5.1", "react-simple-compat": "1.2.3", - "react-test-renderer": "17.0.2", + "react-test-renderer": "18.2.0", "redux-mock-store": "1.5.4", "rimraf": "4.4.0", "rudder-sdk-js": "2.25.0", @@ -294,6 +293,7 @@ "@sentry/browser": "6.19.7", "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", + "@testing-library/react-hooks": "^8.0.1", "@types/react-resizable": "3.0.3", "@types/webpack-env": "1.18.0", "@visx/event": "3.0.1", @@ -366,11 +366,11 @@ "rc-time-picker": "3.7.3", "rc-tree": "5.7.2", "re-resizable": "6.9.9", - "react": "17.0.2", + "react": "18.2.0", "react-awesome-query-builder": "5.4.0", "react-beautiful-dnd": "13.1.1", "react-diff-viewer": "^3.1.1", - "react-dom": "17.0.2", + "react-dom": "18.2.0", "react-draggable": "4.4.5", "react-dropzone": "^14.2.3", "react-enable": "^3.1.0", @@ -385,7 +385,7 @@ "react-redux": "7.2.6", "react-resizable": "3.0.4", "react-reverse-portal": "2.1.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "5.3.3", "react-select": "5.7.0", "react-split-pane": "0.1.92", "react-table": "7.8.0", @@ -437,7 +437,9 @@ "@storybook/manager-webpack5/webpack": "5.76.0", "ngtemplate-loader/loader-utils": "^2.0.0", "trim": "0.0.3", - "slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch" + "slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch", + "react-split-pane@0.1.92": "patch:react-split-pane@npm:0.1.92#.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch", + "@storybook/addon-docs@6.5.16": "patch:@storybook/addon-docs@npm:6.5.16#.yarn/patches/@storybook-addon-docs-npm-6.5.16-56ecbd77e7.patch" }, "workspaces": { "packages": [ diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 70497f3d1ed..7032827909b 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -63,10 +63,9 @@ "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-json": "5.0.1", "@rollup/plugin-node-resolve": "15.0.1", - "@testing-library/dom": "8.20.0", + "@testing-library/dom": "9.0.1", "@testing-library/jest-dom": "5.16.5", - "@testing-library/react": "12.1.4", - "@testing-library/react-hooks": "8.0.1", + "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "@types/dompurify": "^2", "@types/history": "4.7.11", @@ -76,15 +75,15 @@ "@types/marked": "4.0.8", "@types/node": "18.14.6", "@types/papaparse": "5.3.7", - "@types/react": "17.0.42", - "@types/react-dom": "17.0.14", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", "@types/sinon": "10.0.13", "@types/testing-library__jest-dom": "5.14.5", "@types/tinycolor2": "1.4.3", "esbuild": "0.16.17", - "react": "17.0.2", - "react-dom": "17.0.2", - "react-test-renderer": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-test-renderer": "18.2.0", "rimraf": "4.4.0", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", @@ -94,7 +93,7 @@ "typescript": "4.8.4" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" } } diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts index 7181c06fa05..a121376a98a 100644 --- a/packages/grafana-e2e/src/flows/configurePanel.ts +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -141,7 +141,6 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC if (queriesForm) { queriesForm(fullConfig); - e2e().wait('@chartData'); // Wait for a possible complex visualization to render (or something related, as this isn't necessary on the dashboard page) // Can't assert that its HTML changed because a new query could produce the same results @@ -158,8 +157,6 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC // Avoid annotations flakiness e2e.components.RefreshPicker.runButtonV2().first().click({ force: true }); - e2e().wait('@chartData'); - // Wait for RxJS e2e().wait(500); diff --git a/packages/grafana-e2e/src/flows/importDashboard.ts b/packages/grafana-e2e/src/flows/importDashboard.ts index d3b01c2be6b..2d9edeee690 100644 --- a/packages/grafana-e2e/src/flows/importDashboard.ts +++ b/packages/grafana-e2e/src/flows/importDashboard.ts @@ -20,10 +20,9 @@ export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: num e2e().visit(fromBaseUrl('/dashboard/import')); // Note: normally we'd use 'click' and then 'type' here, but the json object is so big that using 'val' is much faster - e2e.components.DashboardImportPage.textarea() - .should('be.visible') - .click() - .invoke('val', JSON.stringify(dashboardToImport)); + e2e.components.DashboardImportPage.textarea().should('be.visible'); + e2e.components.DashboardImportPage.textarea().click(); + e2e.components.DashboardImportPage.textarea().invoke('val', JSON.stringify(dashboardToImport)); e2e.components.DashboardImportPage.submit().should('be.visible').click(); e2e.components.ImportDashboardForm.name().should('be.visible').click().clear().type(dashboardToImport.title); e2e.components.ImportDashboardForm.submit().should('be.visible').click(); diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 67798bf4475..e7ba8738b6c 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -52,21 +52,20 @@ "@grafana/tsconfig": "^1.2.0-rc1", "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-node-resolve": "15.0.1", - "@testing-library/dom": "8.20.0", - "@testing-library/react": "12.1.4", - "@testing-library/react-hooks": "8.0.1", + "@testing-library/dom": "9.0.1", + "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "@types/angular": "1.8.4", "@types/history": "4.7.11", "@types/jest": "29.2.3", "@types/lodash": "4.14.191", - "@types/react": "17.0.42", - "@types/react-dom": "17.0.14", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", "@types/systemjs": "^0.20.6", "esbuild": "0.16.17", "lodash": "4.17.21", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "rimraf": "4.4.0", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", @@ -77,7 +76,7 @@ "typescript": "4.8.4" }, "peerDependencies": { - "react": "17.0.2", - "react-dom": "17.0.2" + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" } } diff --git a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx index 75b52e2cf59..40c2a65bdc9 100644 --- a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx +++ b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import React from 'react'; import { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 09febdfebd2..9185bda19ca 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -97,7 +97,7 @@ "react-inlinesvg": "3.0.2", "react-popper": "2.3.0", "react-popper-tooltip": "4.4.2", - "react-router-dom": "^5.2.0", + "react-router-dom": "5.3.3", "react-select": "5.7.0", "react-select-event": "^5.1.0", "react-table": "7.8.0", @@ -134,10 +134,9 @@ "@storybook/preset-scss": "1.0.3", "@storybook/react": "6.5.16", "@storybook/theming": "6.5.16", - "@testing-library/dom": "8.20.0", + "@testing-library/dom": "9.0.1", "@testing-library/jest-dom": "5.16.5", - "@testing-library/react": "12.1.4", - "@testing-library/react-hooks": "8.0.1", + "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.4.3", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.0", @@ -149,15 +148,15 @@ "@types/mock-raf": "1.0.3", "@types/node": "18.14.6", "@types/prismjs": "1.26.0", - "@types/react": "17.0.42", + "@types/react": "18.0.28", "@types/react-beautiful-dnd": "13.1.3", "@types/react-calendar": "3.9.0", "@types/react-color": "3.0.6", - "@types/react-dom": "17.0.14", + "@types/react-dom": "18.0.11", "@types/react-highlight-words": "0.16.4", "@types/react-router-dom": "5.3.3", "@types/react-table": "7.7.14", - "@types/react-test-renderer": "17.0.1", + "@types/react-test-renderer": "18.0.0", "@types/react-transition-group": "4.4.5", "@types/react-window": "1.8.5", "@types/slate": "0.47.11", @@ -173,9 +172,9 @@ "expose-loader": "4.0.0", "mock-raf": "1.0.1", "process": "^0.11.10", - "react": "17.0.2", - "react-dom": "17.0.2", - "react-test-renderer": "17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-test-renderer": "18.2.0", "rimraf": "4.4.0", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", @@ -190,7 +189,7 @@ "webpack": "5.76.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" } } diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 21f1e28ed8c..3db8ed9c69f 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -71,7 +71,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi timeout={{ appear: animationDuration, exit: 0, enter: 0 }} classNames={animationStyles} > -
{ReactUtils.renderOrCallToRender(overlay)}
+
{ReactUtils.renderOrCallToRender(overlay, {})}
diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx index 72eb6d9084b..df5241e46de 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -57,7 +57,7 @@ describe('ErrorBoundary', () => { expect((faro.api.pushError as jest.Mock).mock.calls[0][0]).toBe(problem); }); - it('should recover when when recover props change', async () => { + it('should rerender when recover props change', async () => { const problem = new Error('things went terribly wrong'); let renderCount = 0; @@ -75,6 +75,8 @@ describe('ErrorBoundary', () => { ); await screen.findByText(problem.message); + expect(renderCount).toBeGreaterThan(0); + const oldRenderCount = renderCount; rerender( @@ -89,6 +91,6 @@ describe('ErrorBoundary', () => { ); - expect(renderCount).toBe(2); + expect(renderCount).toBeGreaterThan(oldRenderCount); }); }); diff --git a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx b/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx index 69873f54e13..607b032878b 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx @@ -10,7 +10,7 @@ const expanderContainerStyles = css` height: 100%; `; -export function ExpanderCell({ row, __rowID }: CellProps & { __rowID: string }) { +export function ExpanderCell({ row, __rowID }: CellProps) { return (
{ + const message = typeof item === 'string' ? item : item.message ?? ''; return (
- {typeof item === 'string' && textUtil.hasAnsiCodes(item) ? : item} + {textUtil.hasAnsiCodes(message) ? : message}
); }} diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx index 5371d1c7674..0a9c729903a 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx @@ -1,5 +1,4 @@ -import { fireEvent } from '@testing-library/dom'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/packages/grafana-ui/src/components/Menu/hooks.test.tsx b/packages/grafana-ui/src/components/Menu/hooks.test.tsx index 94b007a4ab7..254f41c7a8a 100644 --- a/packages/grafana-ui/src/components/Menu/hooks.test.tsx +++ b/packages/grafana-ui/src/components/Menu/hooks.test.tsx @@ -1,6 +1,4 @@ -import { fireEvent } from '@testing-library/dom'; -import { render, screen } from '@testing-library/react'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; import React, { createRef, KeyboardEvent, RefObject } from 'react'; import { useMenuFocus } from './hooks'; diff --git a/packages/grafana-ui/src/components/Segment/Segment.tsx b/packages/grafana-ui/src/components/Segment/Segment.tsx index fd8401c6c82..9f25ceb6243 100644 --- a/packages/grafana-ui/src/components/Segment/Segment.tsx +++ b/packages/grafana-ui/src/components/Segment/Segment.tsx @@ -40,6 +40,7 @@ export function Segment({ if (!expanded) { const label = isObject(value) ? value.label : value; + const labelAsString = label != null ? String(label) : undefined; return (
+ +
+
+ + allowCustomValue={false} + value={options.jsonData.backendType ? backendTypeOptions[options.jsonData.backendType] : undefined} + options={Object.values(backendTypeOptions)} + onChange={(option) => { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + backendType: option.value, + }, + }); + }} + /> + } + tooltip="Select what type of backend you use. This datasource supports both Phlare and Pyroscope backends." + /> +
+
+ {mismatchedBackendType && ( + + )} ); }; + +const backendTypeOptions: Record> = { + phlare: { + label: 'Phlare', + value: 'phlare', + }, + pyroscope: { + label: 'Pyroscope', + value: 'pyroscope', + }, +}; diff --git a/public/app/plugins/datasource/phlare/QueryEditor/LabelsEditor.tsx b/public/app/plugins/datasource/phlare/QueryEditor/LabelsEditor.tsx index 4468ef83d0c..4846b59bc3b 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/LabelsEditor.tsx +++ b/public/app/plugins/datasource/phlare/QueryEditor/LabelsEditor.tsx @@ -1,11 +1,10 @@ import { css } from '@emotion/css'; import React, { useEffect, useRef } from 'react'; -import { useLatest } from 'react-use'; +import { useAsync, useLatest } from 'react-use'; import { CodeEditor, Monaco, useStyles2, monacoTypes } from '@grafana/ui'; import { languageDefinition } from '../phlareql'; -import { SeriesMessage } from '../types'; import { CompletionProvider } from './autocomplete'; @@ -13,11 +12,12 @@ interface Props { value: string; onChange: (val: string) => void; onRunQuery: (value: string) => void; - series?: SeriesMessage; + labels?: string[]; + getLabelValues: (label: string) => Promise; } export function LabelsEditor(props: Props) { - const setupAutocompleteFn = useAutocomplete(props.series); + const setupAutocompleteFn = useAutocomplete(props.getLabelValues, props.labels); const styles = useStyles2(getStyles); const onRunQueryRef = useLatest(props.onRunQuery); @@ -92,15 +92,17 @@ const EDITOR_HEIGHT_OFFSET = 2; /** * Hook that returns function that will set up monaco autocomplete for the label selector */ -function useAutocomplete(series?: SeriesMessage) { - const providerRef = useRef(new CompletionProvider()); +function useAutocomplete(getLabelValues: (label: string) => Promise, labels?: string[]) { + const providerRef = useRef(); + if (providerRef.current === undefined) { + providerRef.current = new CompletionProvider(); + } - useEffect(() => { - if (series) { - // When we have the value we will pass it to the CompletionProvider - providerRef.current.setSeries(series); + useAsync(async () => { + if (providerRef.current) { + providerRef.current.init(labels || [], getLabelValues); } - }, [series]); + }, [labels, getLabelValues]); const autocompleteDisposeFun = useRef<(() => void) | null>(null); useEffect(() => { @@ -112,11 +114,13 @@ function useAutocomplete(series?: SeriesMessage) { // This should be run in monaco onEditorDidMount return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { - providerRef.current.editor = editor; - providerRef.current.monaco = monaco; + if (providerRef.current) { + providerRef.current.editor = editor; + providerRef.current.monaco = monaco; - const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current); - autocompleteDisposeFun.current = dispose; + const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current); + autocompleteDisposeFun.current = dispose; + } }; } @@ -138,7 +142,7 @@ const getStyles = () => { return { queryField: css` flex: 1; - // Not exactly sure but without this the editor doe not shrink after resizing (so you can make it bigger but not + // Not exactly sure but without this the editor does not shrink after resizing (so you can make it bigger but not // smaller). At the same time this does not actually make the editor 100px because it has flex 1 so I assume // this should sort of act as a flex-basis (but flex-basis does not work for this). So yeah CSS magic. width: 100px; diff --git a/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.test.tsx index 95356fa90f1..d9d67521d8e 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.test.tsx @@ -76,20 +76,12 @@ function setup(options: { props: Partial } = { props: {} }) { ds.getProfileTypes = jest.fn().mockResolvedValue([ { - name: 'process_cpu', - ID: 'process_cpu:cpu', - period_type: 'day', - period_unit: 's', - sample_unit: 'ms', - sample_type: 'cpu', + label: 'process_cpu - cpu', + id: 'process_cpu:cpu', }, { - name: 'memory', - ID: 'memory:memory', - period_type: 'day', - period_unit: 's', - sample_unit: 'ms', - sample_type: 'memory', + label: 'memory', + id: 'memory:memory', }, ] as ProfileTypeMessage[]); diff --git a/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.tsx index f9dcfad0339..ea464e2825d 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/phlare/QueryEditor/QueryEditor.tsx @@ -1,13 +1,13 @@ import { defaults } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; -import { CoreApp, QueryEditorProps } from '@grafana/data'; +import { CoreApp, QueryEditorProps, TimeRange } from '@grafana/data'; import { ButtonCascader, CascaderOption } from '@grafana/ui'; -import { defaultPhlare, defaultPhlareQueryType, Phlare } from '../dataquery.gen'; +import { defaultGrafanaPyroscope, defaultPhlareQueryType, GrafanaPyroscope } from '../dataquery.gen'; import { PhlareDataSource } from '../datasource'; -import { PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types'; +import { BackendType, PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types'; import { EditorRow } from './EditorRow'; import { EditorRows } from './EditorRows'; @@ -16,44 +16,32 @@ import { QueryOptions } from './QueryOptions'; export type Props = QueryEditorProps; -export const defaultQuery: Partial = { - ...defaultPhlare, +export const defaultQuery: Partial = { + ...defaultGrafanaPyroscope, queryType: defaultPhlareQueryType, }; export function QueryEditor(props: Props) { - const profileTypes = useProfileTypes(props.datasource); - - function onProfileTypeChange(value: string[], selectedOptions: CascaderOption[]) { - if (selectedOptions.length === 0) { - return; - } - - const id = selectedOptions[selectedOptions.length - 1].value; - - if (typeof id !== 'string') { - throw new Error('id is not string'); - } - - props.onChange({ ...props.query, profileTypeId: id }); - } - - function onLabelSelectorChange(value: string) { - props.onChange({ ...props.query, labelSelector: value }); - } + let query = normalizeQuery(props.query, props.app); function handleRunQuery(value: string) { props.onChange({ ...props.query, labelSelector: value }); props.onRunQuery(); } - const seriesResult = useAsync(() => { - return props.datasource.getSeries(); - }, [props.datasource]); - + const { profileTypes, onProfileTypeChange, selectedProfileName } = useProfileTypes( + props.datasource, + props.query, + props.onChange, + props.datasource.backendType + ); + const { labels, getLabelValues, onLabelSelectorChange } = useLabels( + props.range, + props.datasource, + props.query, + props.onChange + ); const cascaderOptions = useCascaderOptions(profileTypes); - const selectedProfileName = useProfileName(profileTypes, props.query.profileTypeId); - let query = normalizeQuery(props.query, props.app); return ( @@ -65,61 +53,144 @@ export function QueryEditor(props: Props) { value={query.labelSelector} onChange={onLabelSelectorChange} onRunQuery={handleRunQuery} - series={seriesResult.value} + labels={labels} + getLabelValues={getLabelValues} /> - + ); } +function useLabels( + range: TimeRange | undefined, + datasource: PhlareDataSource, + query: Query, + onChange: (value: Query) => void +) { + // Round to nearest 5 seconds. If the range is something like last 1h then every render the range values change slightly + // and what ever has range as dependency is rerun. So this effectively debounces the queries. + const unpreciseRange = { + to: Math.ceil((range?.to.valueOf() || 0) / 5000) * 5000, + from: Math.floor((range?.from.valueOf() || 0) / 5000) * 5000, + }; + + const labelsResult = useAsync(() => { + return datasource.getLabelNames(query.profileTypeId + query.labelSelector, unpreciseRange.from, unpreciseRange.to); + }, [datasource, query.profileTypeId, query.labelSelector, unpreciseRange.to, unpreciseRange.from]); + + // Create a function with range and query already baked in so we don't have to send those everywhere + const getLabelValues = useCallback( + (label: string) => { + return datasource.getLabelValues( + query.profileTypeId + query.labelSelector, + label, + unpreciseRange.from, + unpreciseRange.to + ); + }, + [query, datasource, unpreciseRange.to, unpreciseRange.from] + ); + + const onLabelSelectorChange = useCallback( + (value: string) => { + onChange({ ...query, labelSelector: value }); + }, + [onChange, query] + ); + + return { labels: labelsResult.value, getLabelValues, onLabelSelectorChange }; +} + // Turn profileTypes into cascader options function useCascaderOptions(profileTypes: ProfileTypeMessage[]) { return useMemo(() => { let mainTypes = new Map(); // Classify profile types by name then sample type. for (let profileType of profileTypes) { - if (!mainTypes.has(profileType.name)) { - mainTypes.set(profileType.name, { - label: profileType.name, - value: profileType.ID, + let parts: string[]; + // Phlare uses : as delimiter while Pyro uses . + if (profileType.id.indexOf(':') > -1) { + parts = profileType.id.split(':'); + } else { + parts = profileType.id.split('.'); + const last = parts.pop()!; + parts = [parts.join('.'), last]; + } + + const [name, type] = parts; + + if (!mainTypes.has(name)) { + mainTypes.set(name, { + label: name, + value: profileType.id, children: [], }); } - mainTypes.get(profileType.name)?.children?.push({ - label: profileType.sample_type, - value: profileType.ID, + mainTypes.get(name)?.children?.push({ + label: type, + value: profileType.id, }); } return Array.from(mainTypes.values()); }, [profileTypes]); } -function useProfileTypes(datasource: PhlareDataSource) { +function useProfileTypes( + datasource: PhlareDataSource, + query: Query, + onChange: (value: Query) => void, + backendType: BackendType = 'phlare' +) { const [profileTypes, setProfileTypes] = useState([]); + useEffect(() => { (async () => { const profileTypes = await datasource.getProfileTypes(); setProfileTypes(profileTypes); })(); }, [datasource]); - return profileTypes; + + const onProfileTypeChange = useCallback( + (value: string[], selectedOptions: CascaderOption[]) => { + if (selectedOptions.length === 0) { + return; + } + + const id = selectedOptions[selectedOptions.length - 1].value; + + // Probably cannot happen but makes TS happy + if (typeof id !== 'string') { + throw new Error('id is not string'); + } + + onChange({ ...query, profileTypeId: id }); + }, + [onChange, query] + ); + + const selectedProfileName = useProfileName(profileTypes, query.profileTypeId, backendType); + + return { profileTypes, onProfileTypeChange, selectedProfileName }; } -function useProfileName(profileTypes: ProfileTypeMessage[], profileTypeId: string) { +function useProfileName(profileTypes: ProfileTypeMessage[], profileTypeId: string, backendType: BackendType) { return useMemo(() => { if (!profileTypes) { return 'Loading'; } - const profile = profileTypes.find((type) => type.ID === profileTypeId); + const profile = profileTypes.find((type) => type.id === profileTypeId); if (!profile) { + if (backendType === 'pyroscope') { + return 'Select application'; + } return 'Select a profile type'; } - return profile.name + ' - ' + profile.sample_type; - }, [profileTypeId, profileTypes]); + return profile.label; + }, [profileTypeId, profileTypes, backendType]); } export function normalizeQuery(query: Query, app?: CoreApp | string) { diff --git a/public/app/plugins/datasource/phlare/QueryEditor/QueryOptions.tsx b/public/app/plugins/datasource/phlare/QueryEditor/QueryOptions.tsx index f4f0ba82ffb..56cb059cba9 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/QueryOptions.tsx +++ b/public/app/plugins/datasource/phlare/QueryEditor/QueryOptions.tsx @@ -5,7 +5,7 @@ import { useToggle } from 'react-use'; import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Icon, useStyles2, RadioButtonGroup, MultiSelect } from '@grafana/ui'; -import { Query, SeriesMessage } from '../types'; +import { Query } from '../types'; import { EditorField } from './EditorField'; import { Stack } from './Stack'; @@ -14,7 +14,7 @@ export interface Props { query: Query; onQueryChange: (query: Query) => void; app?: CoreApp; - series?: SeriesMessage; + labels?: string[]; } const typeOptions: Array<{ value: Query['queryType']; label: string; description: string }> = [ @@ -30,28 +30,19 @@ function getTypeOptions(app?: CoreApp) { return typeOptions.filter((option) => option.value !== 'both'); } -function getGroupByOptions(series?: SeriesMessage) { - let options: SelectableValue[] = []; - if (series) { - const labels = series.flatMap((val) => { - return val.labels.map((l) => l.name); - }); - options = Array.from(new Set(labels)).map((l) => ({ - label: l, - value: l, - })); - } - return options; -} - /** * Base on QueryOptionGroup component from grafana/ui but that is not available yet. */ -export function QueryOptions({ query, onQueryChange, app, series }: Props) { +export function QueryOptions({ query, onQueryChange, app, labels }: Props) { const [isOpen, toggleOpen] = useToggle(false); const styles = useStyles2(getStyles); const typeOptions = getTypeOptions(app); - const groupByOptions = getGroupByOptions(series); + const groupByOptions = labels + ? labels.map((l) => ({ + label: l, + value: l, + })) + : []; return ( diff --git a/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.test.ts b/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.test.ts index a729d5919c6..2ea8ddb29cb 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.test.ts +++ b/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.test.ts @@ -1,56 +1,62 @@ import { monacoTypes, Monaco } from '@grafana/ui'; -import { SeriesMessage } from '../types'; - import { CompletionProvider } from './autocomplete'; describe('CompletionProvider', () => { - it('suggests labels', () => { + it('suggests labels', async () => { const { provider, model } = setup('{}', 1, defaultLabels); - const result = provider.provideCompletionItems(model, {} as monacoTypes.Position); + const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'foo', insertText: 'foo' }), ]); }); - it('suggests label names with quotes', () => { + it('suggests label names with quotes', async () => { const { provider, model } = setup('{foo=}', 6, defaultLabels); - const result = provider.provideCompletionItems(model, {} as monacoTypes.Position); + const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'bar', insertText: '"bar"' }), ]); }); - it('suggests label names without quotes', () => { + it('suggests label names without quotes', async () => { const { provider, model } = setup('{foo="}', 7, defaultLabels); - const result = provider.provideCompletionItems(model, {} as monacoTypes.Position); + const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'bar', insertText: 'bar' }), ]); }); - it('suggests nothing without labels', () => { + it('suggests nothing without labels', async () => { const { provider, model } = setup('{foo="}', 7, []); - const result = provider.provideCompletionItems(model, {} as monacoTypes.Position); + const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]); }); - it('suggests labels on empty input', () => { + it('suggests labels on empty input', async () => { const { provider, model } = setup('', 0, defaultLabels); - const result = provider.provideCompletionItems(model, {} as monacoTypes.Position); + const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position); expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([ expect.objectContaining({ label: 'foo', insertText: '{foo="' }), ]); }); }); -const defaultLabels = [{ labels: [{ name: 'foo', value: 'bar' }] }]; +const defaultLabels = ['foo']; -function setup(value: string, offset: number, series?: SeriesMessage) { +function setup(value: string, offset: number, labels: string[] = []) { const provider = new CompletionProvider(); - if (series) { - provider.setSeries(series); - } + provider.init(labels, (label) => { + if (labels.length === 0) { + return Promise.resolve([]); + } + const val = { foo: 'bar' }[label]; + const result = []; + if (val) { + result.push(val); + } + return Promise.resolve(result); + }); const model = makeModel(value, offset); provider.monaco = { Range: { diff --git a/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.ts b/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.ts index 3e61a2d92f3..4db137df402 100644 --- a/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.ts +++ b/public/app/plugins/datasource/phlare/QueryEditor/autocomplete.ts @@ -1,7 +1,5 @@ import { monacoTypes, Monaco } from '@grafana/ui'; -import { SeriesMessage } from '../types'; - /** * Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco * autocomplete system. @@ -16,7 +14,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP monaco: Monaco | undefined; editor: monacoTypes.editor.IStandaloneCodeEditor | undefined; - private labels: { [label: string]: Set } = {}; + private labels: string[] = []; + private getLabelValues: (label: string) => Promise = () => Promise.resolve([]); + + init(labels: string[], getLabelValues: (label: string) => Promise) { + this.labels = labels; + this.getLabelValues = getLabelValues; + } provideCompletionItems( model: monacoTypes.editor.ITextModel, @@ -35,39 +39,21 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP const { range, offset } = getRangeAndOffset(this.monaco, model, position); const situation = getSituation(model.getValue(), offset); - const completionItems = this.getCompletions(situation); - // monaco by-default alphabetically orders the items. - // to stop it, we use a number-as-string sortkey, - // so that monaco keeps the order we use - const maxIndexDigits = completionItems.length.toString().length; - const suggestions: monacoTypes.languages.CompletionItem[] = completionItems.map((item, index) => ({ - kind: getMonacoCompletionItemKind(item.type, this.monaco!), - label: item.label, - insertText: item.insertText, - sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have - range, - })); - return { suggestions }; - } - - /** - * We expect the data directly from the request and transform it here. We do some deduplication and turn them into - * object for quicker search as we usually need either a list of label names or values or particular label. - */ - setSeries(series: SeriesMessage) { - this.labels = series.reduce<{ [label: string]: Set }>((acc, serie) => { - const seriesLabels = serie.labels.reduce<{ [label: string]: Set }>((acc, labelValue) => { - acc[labelValue.name] = acc[labelValue.name] || new Set(); - acc[labelValue.name].add(labelValue.value); - return acc; - }, {}); - - for (const label of Object.keys(seriesLabels)) { - acc[label] = new Set([...(acc[label] || []), ...seriesLabels[label]]); - } - return acc; - }, {}); + return this.getCompletions(situation).then((completionItems) => { + // monaco by-default alphabetically orders the items. + // to stop it, we use a number-as-string sortkey, + // so that monaco keeps the order we use + const maxIndexDigits = completionItems.length.toString().length; + const suggestions: monacoTypes.languages.CompletionItem[] = completionItems.map((item, index) => ({ + kind: getMonacoCompletionItemKind(item.type, this.monaco!), + label: item.label, + insertText: item.insertText, + sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have + range, + })); + return { suggestions }; + }); } /** @@ -75,17 +61,14 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP * @param situation * @private */ - private getCompletions(situation: Situation): Completion[] { - if (!Object.keys(this.labels).length) { - return []; - } + private async getCompletions(situation: Situation): Promise { switch (situation.type) { // Not really sure what would make sense to suggest in this case so just leave it case 'UNKNOWN': { return []; } case 'EMPTY': { - return Object.keys(this.labels).map((key) => { + return this.labels.map((key) => { return { label: key, insertText: `{${key}="`, @@ -94,7 +77,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP }); } case 'IN_LABEL_NAME': - return Object.keys(this.labels).map((key) => { + return this.labels.map((key) => { return { label: key, insertText: key, @@ -102,7 +85,8 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP }; }); case 'IN_LABEL_VALUE': - return Array.from(this.labels[situation.labelName].values()).map((key) => { + let values = await this.getLabelValues(situation.labelName); + return values.map((key) => { return { label: key, insertText: situation.betweenQuotes ? key : `"${key}"`, diff --git a/public/app/plugins/datasource/phlare/dataquery.gen.ts b/public/app/plugins/datasource/phlare/dataquery.gen.ts index d1a33cd4347..0d4936d7867 100644 --- a/public/app/plugins/datasource/phlare/dataquery.gen.ts +++ b/public/app/plugins/datasource/phlare/dataquery.gen.ts @@ -16,7 +16,7 @@ export type PhlareQueryType = ('metrics' | 'profile' | 'both'); export const defaultPhlareQueryType: PhlareQueryType = 'both'; -export interface Phlare extends common.DataQuery { +export interface GrafanaPyroscope extends common.DataQuery { /** * Allows to group the results. */ @@ -31,7 +31,7 @@ export interface Phlare extends common.DataQuery { profileTypeId: string; } -export const defaultPhlare: Partial = { +export const defaultGrafanaPyroscope: Partial = { groupBy: [], labelSelector: '{}', }; diff --git a/public/app/plugins/datasource/phlare/datasource.ts b/public/app/plugins/datasource/phlare/datasource.ts index cb5d7c93c7f..4cf80c31e71 100644 --- a/public/app/plugins/datasource/phlare/datasource.ts +++ b/public/app/plugins/datasource/phlare/datasource.ts @@ -13,14 +13,17 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import { extractLabelMatchers, toPromLikeExpr } from '../prometheus/language_utils'; import { normalizeQuery } from './QueryEditor/QueryEditor'; -import { PhlareDataSourceOptions, Query, ProfileTypeMessage, SeriesMessage } from './types'; +import { PhlareDataSourceOptions, Query, ProfileTypeMessage, BackendType } from './types'; export class PhlareDataSource extends DataSourceWithBackend { + backendType: BackendType; + constructor( instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv() ) { super(instanceSettings); + this.backendType = instanceSettings.jsonData.backendType ?? 'phlare'; } query(request: DataQueryRequest): Observable { @@ -49,13 +52,17 @@ export class PhlareDataSource extends DataSourceWithBackend { - // For now, we send empty matcher to get all the series - return await super.getResource('series', { matchers: ['{}'] }); + async getLabelNames(query: string, start: number, end: number): Promise { + return await super.getResource('labelNames', { query, start, end }); } - async getLabelNames(): Promise { - return await super.getResource('labelNames'); + async getLabelValues(query: string, label: string, start: number, end: number): Promise { + return await super.getResource('labelValues', { label, query, start, end }); + } + + // We need the URL here because it may not be saved on the backend yet when used from config page. + async getBackendType(url: string): Promise<{ backendType: BackendType | 'unknown' }> { + return await super.getResource('backendType', { url }); } applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query { diff --git a/public/app/plugins/datasource/phlare/img/grafana_pyroscope_icon.svg b/public/app/plugins/datasource/phlare/img/grafana_pyroscope_icon.svg new file mode 100644 index 00000000000..7bd26e3cc1d --- /dev/null +++ b/public/app/plugins/datasource/phlare/img/grafana_pyroscope_icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/phlare/img/phlare_icon_color.svg b/public/app/plugins/datasource/phlare/img/phlare_icon_color.svg deleted file mode 100644 index 77ec7133c6e..00000000000 --- a/public/app/plugins/datasource/phlare/img/phlare_icon_color.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/app/plugins/datasource/phlare/plugin.json b/public/app/plugins/datasource/phlare/plugin.json index cd4de7432ad..3ec4ed4eb3b 100644 --- a/public/app/plugins/datasource/phlare/plugin.json +++ b/public/app/plugins/datasource/phlare/plugin.json @@ -1,6 +1,6 @@ { "type": "datasource", - "name": "Phlare", + "name": "Grafana Pyroscope", "id": "phlare", "category": "profiling", @@ -13,15 +13,15 @@ "backend": true, "info": { - "description": "Horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation system. OSS profiling solution from Grafana Labs.", + "description": "Supports Phlare and Pyroscope backends, horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation systems.", "author": { "name": "Grafana Labs", "url": "https://www.grafana.com" }, - "keywords": ["grafana", "datasource", "phlare", "flamegraph"], + "keywords": ["grafana", "datasource", "phlare", "flamegraph", "profiling", "continuous profiling", "pyroscope"], "logos": { - "small": "img/phlare_icon_color.svg", - "large": "img/phlare_icon_color.svg" + "small": "img/grafana_pyroscope_icon.svg", + "large": "img/grafana_pyroscope_icon.svg" }, "links": [ { diff --git a/public/app/plugins/datasource/phlare/types.ts b/public/app/plugins/datasource/phlare/types.ts index 5a52c698e01..52957305684 100644 --- a/public/app/plugins/datasource/phlare/types.ts +++ b/public/app/plugins/datasource/phlare/types.ts @@ -1,25 +1,22 @@ import { DataSourceJsonData } from '@grafana/data'; -import { Phlare as PhlareBase, PhlareQueryType } from './dataquery.gen'; +import { GrafanaPyroscope, PhlareQueryType } from './dataquery.gen'; -export interface Query extends PhlareBase { +export interface Query extends GrafanaPyroscope { queryType: PhlareQueryType; } export interface ProfileTypeMessage { - ID: string; - name: string; - period_type: string; - period_unit: string; - sample_type: string; - sample_unit: string; + id: string; + label: string; } -export type SeriesMessage = Array<{ labels: Array<{ name: string; value: string }> }>; - /** * These are options configured for each DataSource instance. */ export interface PhlareDataSourceOptions extends DataSourceJsonData { minStep?: string; + backendType?: BackendType; // if not set we assume it's phlare } + +export type BackendType = 'phlare' | 'pyroscope'; From fe23c76250b586b9ccb8ac96807834d413a40491 Mon Sep 17 00:00:00 2001 From: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Tue, 25 Apr 2023 17:18:58 +0300 Subject: [PATCH 395/729] Dashboard: New panel in a dashboard is not deleted after "Discard"-ing changes in Panel Edit (#66476) * add isNew notPersistedProperty to PanelModel * if panel is newly created and user "Discard"s it, the panel is removed entirely * add Todo's for when we remove the emptyDashboardPage FF * add isNew to new panel after file dropping on dashboard page * handle the "Apply" case * CSV file dropping is not relevant to a new panel bc it doesnt open edit page --- .../components/AddPanelWidget/AddPanelWidget.tsx | 1 + .../components/PanelEditor/state/actions.ts | 12 +++++++++++- .../features/dashboard/containers/DashboardPage.tsx | 1 + .../features/dashboard/dashgrid/DashboardGrid.tsx | 1 + .../app/features/dashboard/state/DashboardModel.ts | 1 + public/app/features/dashboard/state/PanelModel.ts | 2 ++ public/app/features/dashboard/utils/dashboard.ts | 1 + 7 files changed, 18 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index becd09aa7d7..06803d80127 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -77,6 +77,7 @@ export const AddPanelWidgetUnconnected = ({ panel, dashboard }: Props) => { title: 'Panel Title', datasource: panel.datasource, gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h }, + isNew: true, }; dashboard.addPanel(newPanel); diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.ts index 2a2bbff694c..e01fda2c028 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.ts @@ -1,6 +1,7 @@ import { pick } from 'lodash'; import store from 'app/core/store'; +import { removePanel } from 'app/features/dashboard/utils/panel'; import { cleanUpPanelState } from 'app/features/panel/state/actions'; import { panelModelAndPluginReady } from 'app/features/panel/state/reducers'; import { ThunkResult } from 'app/types'; @@ -113,9 +114,9 @@ export function exitPanelEditor(): ThunkResult { dashboard.exitPanelEditor(); } + const sourcePanel = getSourcePanel(); if (hasPanelChangedInPanelEdit(panel) && !shouldDiscardChanges) { const modifiedSaveModel = panel.getSaveModel(); - const sourcePanel = getSourcePanel(); const panelTypeChanged = sourcePanel.type !== panel.type; dispatch(updateDuplicateLibraryPanels(panel, dashboard)); @@ -144,6 +145,15 @@ export function exitPanelEditor(): ThunkResult { }, 20); } + // A new panel is only new until the first time we exit the panel editor + if (sourcePanel.isNew) { + if (!shouldDiscardChanges) { + delete sourcePanel.isNew; + } else { + dashboard && removePanel(dashboard, sourcePanel, true); + } + } + dispatch(cleanUpPanelState(panel.key)); dispatch(closeEditor()); }; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index febdac4d4b6..d05e2bcac58 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -351,6 +351,7 @@ export class UnthemedDashboardPage extends PureComponent { return updateStatePageNavFromProps(props, updatedState); } + // Todo: Remove this when we remove the emptyDashboardPage toggle onAddPanel = () => { const { dashboard } = this.props; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index f1c7ced4404..efdfc481df9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -185,6 +185,7 @@ export class DashboardGrid extends PureComponent { return ; } + // Todo: Remove this when we remove the emptyDashboardPage toggle if (panel.type === 'add-panel') { return ; } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 16067227c18..588b42dfb6b 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -293,6 +293,7 @@ export class DashboardModel implements TimeModel { } private getPanelSaveModels() { + // Todo: Remove panel.type === 'add-panel' when we remove the emptyDashboardPage toggle return this.panels .filter( (panel) => diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index a38cafe238d..dff99dbd546 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -66,6 +66,7 @@ const notPersistedProperties: { [str: string]: boolean } = { getDisplayTitle: true, dataSupport: true, key: true, + isNew: true, }; // For angular panels we need to clean up properties when changing type @@ -189,6 +190,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { hasRefreshed?: boolean; cacheTimeout?: string | null; queryCachingTTL?: number | null; + isNew?: boolean; cachedPluginOptions: Record = {}; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; diff --git a/public/app/features/dashboard/utils/dashboard.ts b/public/app/features/dashboard/utils/dashboard.ts index 13fe36eecaa..3b31aec7c09 100644 --- a/public/app/features/dashboard/utils/dashboard.ts +++ b/public/app/features/dashboard/utils/dashboard.ts @@ -12,6 +12,7 @@ export function onCreateNewPanel(dashboard: DashboardModel): number | undefined type: 'timeseries', title: 'Panel Title', gridPos: calculateNewPanelGridPos(dashboard), + isNew: true, }; dashboard.addPanel(newPanel); From 39a3d85514553e87bd17e516bcf97373e14ad9f6 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Tue, 25 Apr 2023 10:23:23 -0400 Subject: [PATCH 396/729] Graphite: Variable editor add definition to onChange (#66895) add definition to onChange --- .../components/GraphiteVariableEditor.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/public/app/plugins/datasource/graphite/components/GraphiteVariableEditor.tsx b/public/app/plugins/datasource/graphite/components/GraphiteVariableEditor.tsx index 1a9c0488830..fdf421e27ca 100644 --- a/public/app/plugins/datasource/graphite/components/GraphiteVariableEditor.tsx +++ b/public/app/plugins/datasource/graphite/components/GraphiteVariableEditor.tsx @@ -8,7 +8,7 @@ import { convertToGraphiteQueryObject } from './helpers'; interface Props { query: GraphiteQuery | string; - onChange: (query: GraphiteQuery) => void; + onChange: (query: GraphiteQuery, definition: string) => void; } const GRAPHITE_QUERY_VARIABLE_TYPE_OPTIONS = [ @@ -36,10 +36,13 @@ export const GraphiteVariableEditor = (props: Props) => { }); if (value.target) { - onChange({ - ...value, - queryType: selectableValue.value, - }); + onChange( + { + ...value, + queryType: selectableValue.value, + }, + value.target ?? '' + ); } }} /> @@ -48,7 +51,7 @@ export const GraphiteVariableEditor = (props: Props) => { onChange(value)} + onBlur={() => onChange(value, value.target ?? '')} onChange={(e) => { setValue({ ...value, From 93348c2a178df16632c3a436880fd02f440768d2 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 25 Apr 2023 07:47:22 -0700 Subject: [PATCH 397/729] Spreadsheet: Convert to DataFrame (#67170) --- public/app/core/utils/sheet.test.ts | 183 ++++++++++++++++++++++++++++ public/app/core/utils/sheet.ts | 176 ++++++++++++++++++++++++-- 2 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 public/app/core/utils/sheet.test.ts diff --git a/public/app/core/utils/sheet.test.ts b/public/app/core/utils/sheet.test.ts new file mode 100644 index 00000000000..cb8b46ecfeb --- /dev/null +++ b/public/app/core/utils/sheet.test.ts @@ -0,0 +1,183 @@ +import { utils } from 'xlsx'; + +import { DataFrame } from '@grafana/data'; + +import { workSheetToFrame } from './sheet'; + +describe('sheets', () => { + it('will use first row as names', () => { + const sheet = utils.aoa_to_sheet([ + ['Number', 'String', 'Bool', 'Date', 'Object'], + [1, 'A', true, Date.UTC(2020, 1, 1), { hello: 'world' }], + [2, 'B', false, Date.UTC(2020, 1, 2), { hello: 'world' }], + ]); + const frame = workSheetToFrame(sheet); + + expect(toSnapshotFrame(frame)).toMatchInlineSnapshot(` + [ + { + "name": "Number", + "type": "number", + "values": [ + 1, + 2, + ], + }, + { + "name": "String", + "type": "string", + "values": [ + "A", + "B", + ], + }, + { + "name": "Bool", + "type": "boolean", + "values": [ + true, + false, + ], + }, + { + "name": "Date", + "type": "number", + "values": [ + 1580515200000, + 1580601600000, + ], + }, + { + "name": "Object", + "type": "string", + "values": [ + undefined, + undefined, + ], + }, + ] + `); + }); + + it('will use calculated data when cells are typed', () => { + const sheet = utils.aoa_to_sheet([ + [1, 'A', true, Date.UTC(2020, 1, 1), { hello: 'world' }], + [2, 'B', false, Date.UTC(2020, 1, 2), { hello: 'world' }], + [3, 'C', true, Date.UTC(2020, 1, 3), { hello: 'world' }], + ]); + const frame = workSheetToFrame(sheet); + + expect(toSnapshotFrame(frame)).toMatchInlineSnapshot(` + [ + { + "name": "A", + "type": "number", + "values": [ + 1, + 2, + 3, + ], + }, + { + "name": "B", + "type": "string", + "values": [ + "A", + "B", + "C", + ], + }, + { + "name": "C", + "type": "boolean", + "values": [ + true, + false, + true, + ], + }, + { + "name": "D", + "type": "number", + "values": [ + 1580515200000, + 1580601600000, + 1580688000000, + ], + }, + { + "name": "E", + "type": "string", + "values": [ + undefined, + undefined, + undefined, + ], + }, + ] + `); + }); + + it('is OK with nulls and undefineds, and misalignment', () => { + const sheet = utils.aoa_to_sheet([ + [null, 'A', true], + [2, 'B', null, Date.UTC(2020, 1, 2), { hello: 'world' }], + [3, 'C', true, undefined, { hello: 'world' }], + ]); + const frame = workSheetToFrame(sheet); + + expect(toSnapshotFrame(frame)).toMatchInlineSnapshot(` + [ + { + "name": "A", + "type": "number", + "values": [ + undefined, + 2, + 3, + ], + }, + { + "name": "B", + "type": "string", + "values": [ + "A", + "B", + "C", + ], + }, + { + "name": "C", + "type": "boolean", + "values": [ + true, + undefined, + true, + ], + }, + { + "name": "D", + "type": "number", + "values": [ + undefined, + 1580601600000, + undefined, + ], + }, + { + "name": "E", + "type": "string", + "values": [ + undefined, + undefined, + undefined, + ], + }, + ] + `); + }); +}); + +function toSnapshotFrame(frame: DataFrame) { + return frame.fields.map((f) => ({ name: f.name, values: f.values, type: f.type })); +} diff --git a/public/app/core/utils/sheet.ts b/public/app/core/utils/sheet.ts index 5771b772283..a97d31c2b33 100644 --- a/public/app/core/utils/sheet.ts +++ b/public/app/core/utils/sheet.ts @@ -1,12 +1,172 @@ -import { read, utils } from 'xlsx'; +import { read, utils, WorkSheet, WorkBook, Range, ColInfo, CellObject, ExcelDataType } from 'xlsx'; -import { ArrayDataFrame, DataFrame } from '@grafana/data'; +import { DataFrame, FieldType } from '@grafana/data'; export function readSpreadsheet(file: ArrayBuffer): DataFrame[] { - const wb = read(file, { type: 'buffer' }); - return wb.SheetNames.map((name) => { - const frame = new ArrayDataFrame(utils.sheet_to_json(wb.Sheets[name])); - frame.name = name; - return frame; - }); + return workBookToFrames(read(file, { type: 'buffer' })); +} + +export function workBookToFrames(wb: WorkBook): DataFrame[] { + return wb.SheetNames.map((name) => workSheetToFrame(wb.Sheets[name], name)); +} + +export function workSheetToFrame(sheet: WorkSheet, name?: string): DataFrame { + const columns = sheetAsColumns(sheet); + if (!columns?.length) { + return { + fields: [], + name: name, + length: 0, + }; + } + + return { + fields: columns.map((c, idx) => { + let type = FieldType.string; + let values: unknown[] = []; + switch (c.type ?? 's') { + case 'b': + type = FieldType.boolean; + values = c.data.map((v) => (v?.v == null ? v?.v : Boolean(v.v))); + break; + + case 'n': + type = FieldType.number; + values = c.data.map((v) => (v?.v == null ? v?.v : +v.v)); + break; + + case 'd': + type = FieldType.time; + values = c.data.map((v) => (v?.v == null ? v?.v : +v.v)); // ??? + break; + + default: + type = FieldType.string; + values = c.data.map((v) => (v?.v == null ? v?.v : utils.format_cell(v))); + break; + } + + return { + name: c.name, + config: {}, // TODO? we could apply decimal formatting from worksheet + type, + values, + }; + }), + name: name, + length: columns[0].data.length, + }; +} + +interface ColumnData { + index: number; + name: string; + info?: ColInfo; + data: CellObject[]; + type?: ExcelDataType; +} + +function sheetAsColumns(sheet: WorkSheet): ColumnData[] | null { + const r = sheet['!ref']; + if (!r) { + return null; + } + const columnInfo = sheet['!cols']; + const cols: ColumnData[] = []; + const range = safe_decode_range(r); + const types = new Set(); + let firstRowIsHeader = true; + + for (let c = range.s.c; c <= range.e.c; ++c) { + types.clear(); + const info = columnInfo?.[c] ?? {}; + if (info.hidden) { + continue; // skip the column + } + const field: ColumnData = { + index: c, + name: utils.encode_col(c), + data: [], + info, + }; + const pfix = utils.encode_col(c); + for (let r = range.s.r; r <= range.e.r; ++r) { + const cell = sheet[pfix + utils.encode_row(r)]; + if (cell) { + if (field.data.length) { + types.add(cell.t); + } else if (cell.t !== 's') { + firstRowIsHeader = false; + } + } + field.data.push(cell); + } + cols.push(field); + if (types.size === 1) { + field.type = Array.from(types)[0]; + } + } + + if (firstRowIsHeader) { + return cols.map((c) => { + const first = c.data[0]; + if (first?.v) { + c.name = utils.format_cell(first); + } + c.data = c.data.slice(1); + return c; + }); + } + return cols; +} + +/** + * Copied from Apache 2 licensed sheetjs: + * https://git.sheetjs.com/sheetjs/sheetjs/src/branch/master/xlsx.flow.js#L4338 + */ +function safe_decode_range(range: string): Range { + let o = { s: { c: 0, r: 0 }, e: { c: 0, r: 0 } }; + let idx = 0, + i = 0, + cc = 0; + let len = range.length; + for (idx = 0; i < len; ++i) { + if ((cc = range.charCodeAt(i) - 64) < 1 || cc > 26) { + break; + } + idx = 26 * idx + cc; + } + o.s.c = --idx; + + for (idx = 0; i < len; ++i) { + if ((cc = range.charCodeAt(i) - 48) < 0 || cc > 9) { + break; + } + idx = 10 * idx + cc; + } + o.s.r = --idx; + + if (i === len || cc !== 10) { + o.e.c = o.s.c; + o.e.r = o.s.r; + return o; + } + ++i; + + for (idx = 0; i !== len; ++i) { + if ((cc = range.charCodeAt(i) - 64) < 1 || cc > 26) { + break; + } + idx = 26 * idx + cc; + } + o.e.c = --idx; + + for (idx = 0; i !== len; ++i) { + if ((cc = range.charCodeAt(i) - 48) < 0 || cc > 9) { + break; + } + idx = 10 * idx + cc; + } + o.e.r = --idx; + return o; } From bb66f14c1d089b12d32e1afc7bccc7fc2e937e5e Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 25 Apr 2023 16:22:36 +0100 Subject: [PATCH 398/729] NestedFolders: Rename 'General' to 'Dashboards' in FolderPicker (#67113) --- public/app/core/components/Select/FolderPicker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index e3d9bca1557..96b9f0be4de 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -5,6 +5,7 @@ import { useAsync } from 'react-use'; import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; @@ -70,7 +71,7 @@ export function FolderPicker(props: Props) { initialFolderUid, initialTitle = '', permissionLevel = PermissionLevelString.Edit, - rootName = 'General', + rootName: rootNameProp, showRoot = true, skipInitialLoad, searchQueryType, @@ -78,6 +79,8 @@ export function FolderPicker(props: Props) { folderWarning, } = props; + const rootName = rootNameProp ?? config.featureToggles.nestedFolders ? 'Dashboards' : 'General'; + const [folder, setFolder] = useState(null); const [isCreatingNew, setIsCreatingNew] = useState(false); const [inputValue, setInputValue] = useState(''); From e6e741546ffb8fd4d11e7a1e765d49f831f0bd18 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 25 Apr 2023 17:08:40 +0100 Subject: [PATCH 399/729] Nested folders: Create basic Move/Delete modals (#67140) * add modal scaffolding * add some unit tests * remove dummy api, add some TODO comments * small test refactor * another small test refactor * fix unit tests due to aria-label/data-testid change --- .betterer.results | 3 - .../src/selectors/pages.ts | 2 +- .../ConfirmModal/ConfirmModal.test.tsx | 7 +- .../components/ConfirmModal/ConfirmModal.tsx | 2 +- .../grafana-ui/src/components/Modal/Modal.tsx | 2 +- .../admin/AlertmanagerConfig.test.tsx | 2 +- .../BrowseDashboardsPage.tsx | 2 +- .../components/BrowseActions.tsx | 41 ------- .../BrowseActions.test.tsx | 7 +- .../BrowseActions/BrowseActions.tsx | 67 ++++++++++++ .../BrowseActions/DeleteModal.test.tsx | 72 +++++++++++++ .../components/BrowseActions/DeleteModal.tsx | 62 +++++++++++ .../BrowseActions/MoveModal.test.tsx | 101 ++++++++++++++++++ .../components/BrowseActions/MoveModal.tsx | 65 +++++++++++ .../components/BrowseActions/utils.test.ts | 23 ++++ .../components/BrowseActions/utils.ts | 26 +++++ .../folders/FolderSettingsPage.test.tsx | 2 +- .../components/ConfirmDeleteModal.test.tsx | 5 +- .../ServiceAccountsListPage.test.tsx | 4 +- .../LogGroups/SelectedLogGroups.test.tsx | 2 +- 20 files changed, 436 insertions(+), 61 deletions(-) delete mode 100644 public/app/features/browse-dashboards/components/BrowseActions.tsx rename public/app/features/browse-dashboards/components/{ => BrowseActions}/BrowseActions.test.tsx (59%) create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/utils.test.ts create mode 100644 public/app/features/browse-dashboards/components/BrowseActions/utils.ts diff --git a/.betterer.results b/.betterer.results index 278d3a84a8a..34133989d8f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -962,9 +962,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx:5381": [ - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] - ], "packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index b7f1ca5448b..95cd03e5b25 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -38,7 +38,7 @@ export const Pages = { dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`, }, ConfirmModal: { - delete: 'Confirm Modal Danger Button', + delete: 'data-testid Confirm Modal Danger Button', }, AddDashboard: { url: '/dashboard/new', diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx index 8c7a2f93c88..7cd0cf04d9f 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { ConfirmModal } from './ConfirmModal'; @@ -23,8 +23,7 @@ describe('ConfirmModal', () => { expect(screen.getByText('Some Body')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Dismiss Text' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Alternative Text' })).toBeInTheDocument(); - const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' }); - expect(within(button).getByText('Please Confirm')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeInTheDocument(); }); it('should render nothing when isOpen is false', () => { @@ -43,6 +42,6 @@ describe('ConfirmModal', () => { expect(screen.queryByText('Some Body')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Dismiss Text' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Alternative Text' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Confirm Modal Danger Button' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Confirm' })).not.toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index 432deafa452..361d0bb8385 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -105,7 +105,7 @@ export const ConfirmModal = ({ onClick={onConfirm} disabled={disabled} ref={buttonRef} - aria-label={selectors.pages.ConfirmModal.delete} + data-testid={selectors.pages.ConfirmModal.delete} > {confirmText} diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index 14000353ec2..599dedb87a7 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -84,7 +84,7 @@ export function Modal(props: PropsWithChildren) { typeof title !== 'string' && title }
- +
{children}
diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx index 5b6df00d2f9..1d4a4abff06 100644 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx @@ -75,7 +75,7 @@ const dataSources = { }; const ui = { - confirmButton: byRole('button', { name: /Confirm Modal Danger Button/ }), + confirmButton: byRole('button', { name: /Yes, reset configuration/ }), resetButton: byRole('button', { name: /Reset configuration/ }), saveButton: byRole('button', { name: /Save/ }), configInput: byLabelText(/Configuration/), diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 976df59e66b..34c13125aa3 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -12,7 +12,7 @@ import { buildNavModel } from '../folders/state/navModel'; import { parseRouteParams } from '../search/utils'; import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI'; -import { BrowseActions } from './components/BrowseActions'; +import { BrowseActions } from './components/BrowseActions/BrowseActions'; import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; import { SearchView } from './components/SearchView'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions.tsx deleted file mode 100644 index 602707cf35e..00000000000 --- a/public/app/features/browse-dashboards/components/BrowseActions.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, useStyles2 } from '@grafana/ui'; - -export interface Props {} - -export function BrowseActions() { - const styles = useStyles2(getStyles); - - const onMove = () => { - // TODO real implemenation, stub for now - console.log('onMoveClicked'); - }; - - const onDelete = () => { - // TODO real implementation, stub for now - console.log('onDeleteClicked'); - }; - - return ( -
- - -
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - row: css({ - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(1), - marginBottom: theme.spacing(2), - }), -}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.test.tsx similarity index 59% rename from public/app/features/browse-dashboards/components/BrowseActions.test.tsx rename to public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.test.tsx index ee56379c92b..376600e007d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.test.tsx @@ -1,8 +1,13 @@ -import { render, screen } from '@testing-library/react'; +import { render as rtlRender, screen } from '@testing-library/react'; import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; import { BrowseActions } from './BrowseActions'; +function render(...[ui, options]: Parameters) { + rtlRender({ui}, options); +} + describe('browse-dashboards BrowseActions', () => { it('displays Move and Delete buttons', () => { render(); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx new file mode 100644 index 00000000000..9d5aca6f4d4 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -0,0 +1,67 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; + +import { useSelectedItemsState } from '../../state'; + +import { DeleteModal } from './DeleteModal'; +import { MoveModal } from './MoveModal'; + +export interface Props {} + +export function BrowseActions() { + const styles = useStyles2(getStyles); + const selectedItems = useSelectedItemsState(); + + const onMove = () => { + appEvents.publish( + new ShowModalReactEvent({ + component: MoveModal, + props: { + selectedItems, + onConfirm: (moveTarget: string) => { + console.log(`MoveModal onConfirm clicked with target ${moveTarget}!`); + }, + }, + }) + ); + }; + + const onDelete = () => { + appEvents.publish( + new ShowModalReactEvent({ + component: DeleteModal, + props: { + selectedItems, + onConfirm: () => { + console.log('DeleteModal onConfirm clicked!'); + }, + }, + }) + ); + }; + + return ( +
+ + +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + row: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(1), + marginBottom: theme.spacing(2), + }), +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx new file mode 100644 index 00000000000..95d1f53993f --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DeleteModal, Props } from './DeleteModal'; + +describe('browse-dashboards DeleteModal', () => { + const mockOnDismiss = jest.fn(); + const mockOnConfirm = jest.fn(); + + const defaultProps: Props = { + isOpen: true, + onConfirm: mockOnConfirm, + onDismiss: mockOnDismiss, + selectedItems: { + folder: {}, + dashboard: {}, + panel: {}, + }, + }; + + it('renders a dialog with the correct title', async () => { + render(); + + expect(await screen.findByRole('dialog', { name: 'Delete Compute Resources' })).toBeInTheDocument(); + }); + + it('displays a `Delete` button', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('displays a `Cancel` button', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('only enables the `Delete` button if the confirmation text is typed', async () => { + render(); + + const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm'); + await userEvent.type(confirmationInput, 'Delete'); + + expect(await screen.findByRole('button', { name: 'Delete' })).toBeEnabled(); + }); + + it('calls onConfirm when clicking the `Delete` button', async () => { + render(); + + const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm'); + await userEvent.type(confirmationInput, 'Delete'); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + it('calls onDismiss when clicking the `Cancel` button', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + expect(mockOnDismiss).toHaveBeenCalled(); + }); + + it('calls onDismiss when clicking the X', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' })); + expect(mockOnDismiss).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx new file mode 100644 index 00000000000..6bba8ea1839 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, isTruthy } from '@grafana/data'; +import { ConfirmModal, useStyles2 } from '@grafana/ui'; + +import { DashboardTreeSelection } from '../../types'; + +import { buildBreakdownString } from './utils'; + +export interface Props { + isOpen: boolean; + onConfirm: () => void; + onDismiss: () => void; + selectedItems: DashboardTreeSelection; +} + +export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { + const styles = useStyles2(getStyles); + + // TODO abstract all this counting logic out + const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length; + const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length; + // hardcoded values for now + // TODO replace with dummy API + const libraryPanelCount = 1; + const alertRuleCount = 1; + + const onDelete = () => { + onConfirm(); + onDismiss(); + }; + + return ( + + This action will delete the following content: +

+ {buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)} +

+ + } + confirmationText="Delete" + confirmText="Delete" + onDismiss={onDismiss} + onConfirm={onDelete} + title="Delete Compute Resources" + {...props} + /> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + breakdown: css({ + ...theme.typography.bodySmall, + color: theme.colors.text.secondary, + }), + modalBody: css({ + ...theme.typography.body, + }), +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx new file mode 100644 index 00000000000..21f37c6aad9 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; + +import * as api from 'app/features/manage-dashboards/state/actions'; +import { DashboardSearchHit } from 'app/features/search/types'; + +import { MoveModal, Props } from './MoveModal'; + +describe('browse-dashboards MoveModal', () => { + const mockOnDismiss = jest.fn(); + const mockOnConfirm = jest.fn(); + const mockFolders = [ + { title: 'General', uid: '' } as DashboardSearchHit, + { title: 'Folder 1', uid: 'wfTJJL5Wz' } as DashboardSearchHit, + ]; + let props: Props; + + beforeEach(() => { + props = { + isOpen: true, + onConfirm: mockOnConfirm, + onDismiss: mockOnDismiss, + selectedItems: { + folder: {}, + dashboard: {}, + panel: {}, + }, + }; + + // mock the searchFolders api call so the folder picker has some folders in it + jest.spyOn(api, 'searchFolders').mockResolvedValue(mockFolders); + }); + + it('renders a dialog with the correct title', async () => { + render(); + + expect(await screen.findByRole('dialog', { name: 'Move' })).toBeInTheDocument(); + }); + + it('displays a `Move` button', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Move' })).toBeInTheDocument(); + }); + + it('displays a `Cancel` button', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('displays a folder picker', async () => { + render(); + + expect(await screen.findByRole('combobox', { name: 'Select a folder' })).toBeInTheDocument(); + }); + + it('displays a warning about permissions if a folder is selected', async () => { + props.selectedItems.folder = { + myFolderUid: true, + }; + render(); + + expect(await screen.findByText('Moving this item may change its permissions.')).toBeInTheDocument(); + }); + + it('only enables the `Move` button if a folder is selected', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Move' })).toBeDisabled(); + const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); + + await selectOptionInTest(folderPicker, mockFolders[1].title); + expect(await screen.findByRole('button', { name: 'Move' })).toBeEnabled(); + }); + + it('calls onConfirm when clicking the `Move` button', async () => { + render(); + const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); + + await selectOptionInTest(folderPicker, mockFolders[1].title); + await userEvent.click(await screen.findByRole('button', { name: 'Move' })); + expect(mockOnConfirm).toHaveBeenCalledWith(mockFolders[1].uid); + }); + + it('calls onDismiss when clicking the `Cancel` button', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + expect(mockOnDismiss).toHaveBeenCalled(); + }); + + it('calls onDismiss when clicking the X', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' })); + expect(mockOnDismiss).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx new file mode 100644 index 00000000000..8c8ac827dd2 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx @@ -0,0 +1,65 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2, isTruthy } from '@grafana/data'; +import { Alert, Button, Field, Modal, useStyles2 } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; + +import { DashboardTreeSelection } from '../../types'; + +import { buildBreakdownString } from './utils'; + +export interface Props { + isOpen: boolean; + onConfirm: (targetFolderUid: string) => void; + onDismiss: () => void; + selectedItems: DashboardTreeSelection; +} + +export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { + const [moveTarget, setMoveTarget] = useState(); + const styles = useStyles2(getStyles); + + // TODO abstract all this counting logic out + const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length; + const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length; + // hardcoded values for now + // TODO replace with dummy API + const libraryPanelCount = 1; + const alertRuleCount = 1; + + const onMove = () => { + if (moveTarget !== undefined) { + onConfirm(moveTarget); + } + onDismiss(); + }; + + return ( + + {folderCount > 0 && } + This action will move the following content: +

+ {buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)} +

+ + setMoveTarget(uid)} /> + + + + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + breakdown: css({ + ...theme.typography.bodySmall, + color: theme.colors.text.secondary, + }), +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/utils.test.ts b/public/app/features/browse-dashboards/components/BrowseActions/utils.test.ts new file mode 100644 index 00000000000..eb3fc3c911b --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/utils.test.ts @@ -0,0 +1,23 @@ +import { buildBreakdownString } from './utils'; + +describe('browse-dashboards utils', () => { + describe('buildBreakdownString', () => { + it.each` + folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected + ${0} | ${0} | ${0} | ${0} | ${'0 items'} + ${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'} + ${2} | ${0} | ${0} | ${0} | ${'2 items: 2 folders'} + ${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'} + ${0} | ${2} | ${0} | ${0} | ${'2 items: 2 dashboards'} + ${1} | ${0} | ${1} | ${1} | ${'3 items: 1 folder, 1 library panel, 1 alert rule'} + ${2} | ${0} | ${3} | ${4} | ${'9 items: 2 folders, 3 library panels, 4 alert rules'} + ${1} | ${1} | ${1} | ${1} | ${'4 items: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'} + ${1} | ${2} | ${3} | ${4} | ${'10 items: 1 folder, 2 dashboards, 3 library panels, 4 alert rules'} + `( + 'returns the correct message for the various inputs', + ({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => { + expect(buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)).toEqual(expected); + } + ); + }); +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/utils.ts b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts new file mode 100644 index 00000000000..829c16ae1f2 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts @@ -0,0 +1,26 @@ +export function buildBreakdownString( + folderCount: number, + dashboardCount: number, + libraryPanelCount: number, + alertRuleCount: number +) { + const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount; + const parts = []; + if (folderCount) { + parts.push(`${folderCount} ${folderCount === 1 ? 'folder' : 'folders'}`); + } + if (dashboardCount) { + parts.push(`${dashboardCount} ${dashboardCount === 1 ? 'dashboard' : 'dashboards'}`); + } + if (libraryPanelCount) { + parts.push(`${libraryPanelCount} ${libraryPanelCount === 1 ? 'library panel' : 'library panels'}`); + } + if (alertRuleCount) { + parts.push(`${alertRuleCount} ${alertRuleCount === 1 ? 'alert rule' : 'alert rules'}`); + } + let breakdownString = `${total} ${total === 1 ? 'item' : 'items'}`; + if (parts.length > 0) { + breakdownString += `: ${parts.join(', ')}`; + } + return breakdownString; +} diff --git a/public/app/features/folders/FolderSettingsPage.test.tsx b/public/app/features/folders/FolderSettingsPage.test.tsx index 21c1dbd3f2d..18b0e40ead8 100644 --- a/public/app/features/folders/FolderSettingsPage.test.tsx +++ b/public/app/features/folders/FolderSettingsPage.test.tsx @@ -184,7 +184,7 @@ describe('FolderSettingsPage', () => { await userEvent.click(deleteButton); const deleteModal = screen.getByRole('dialog', { name: 'Delete' }); expect(deleteModal).toBeInTheDocument(); - const deleteButtonModal = within(deleteModal).getByRole('button', { name: 'Confirm Modal Danger Button' }); + const deleteButtonModal = within(deleteModal).getByRole('button', { name: 'Delete' }); await userEvent.click(deleteButtonModal); expect(mockDeleteFolder).toHaveBeenCalledWith(mockFolder.uid); }); diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx index ac48422fb8e..46ba8c50910 100644 --- a/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx +++ b/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { config } from 'app/core/config'; @@ -14,8 +14,7 @@ describe('ConfirmModal', () => { expect(screen.getByRole('heading', { name: 'Delete' })).toBeInTheDocument(); expect(screen.getByText('Do you want to delete the 2 selected dashboards?')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' }); - expect(within(button).getByText('Delete')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); expect(screen.queryByPlaceholderText('Type delete to confirm')).not.toBeInTheDocument(); }); diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx index ee941a3d043..8ccb8387f81 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -135,7 +135,7 @@ describe('ServiceAccountsListPage tests', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: /Disable/ })); - await user.click(screen.getByLabelText(/Confirm Modal Danger Button/)); + await user.click(screen.getByRole('button', { name: 'Disable service account' })); expect(updateServiceAccountMock).toHaveBeenCalledWith({ ...getDefaultServiceAccount(), @@ -152,7 +152,7 @@ describe('ServiceAccountsListPage tests', () => { const user = userEvent.setup(); await user.click(screen.getByLabelText(/Delete service account/)); - await user.click(screen.getByLabelText(/Confirm Modal Danger Button/)); + await user.click(screen.getByRole('button', { name: 'Delete' })); expect(deleteServiceAccountMock).toHaveBeenCalledWith(42); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/LogGroups/SelectedLogGroups.test.tsx b/public/app/plugins/datasource/cloudwatch/components/LogGroups/SelectedLogGroups.test.tsx index 43b9830fb76..01dcae351f9 100644 --- a/public/app/plugins/datasource/cloudwatch/components/LogGroups/SelectedLogGroups.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/LogGroups/SelectedLogGroups.test.tsx @@ -68,7 +68,7 @@ describe('SelectedLogsGroups', () => { await waitFor(() => expect(screen.getByText('Are you sure you want to clear all log groups?')).toBeInTheDocument() ); - await waitFor(() => userEvent.click(screen.getByLabelText('Confirm Modal Danger Button'))); + await waitFor(() => userEvent.click(screen.getByRole('button', { name: 'Yes' }))); expect(defaultProps.onChange).toHaveBeenCalledWith([]); }); }); From 2a67b8ad32a49d13b94af173fcceba9efbdbfa3b Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Tue, 25 Apr 2023 18:16:46 +0200 Subject: [PATCH 400/729] Panel Header Fix: Implement new Panel Header on Angular Panels (#66826) Co-authored-by: Dominik Prokop --- .../dashboard/dashgrid/PanelChromeAngular.tsx | 86 +++++++++---- .../dashboard/dashgrid/PanelStateWrapper.tsx | 94 ++------------ .../dashboard/utils/getPanelChromeProps.tsx | 120 ++++++++++++++++++ 3 files changed, 193 insertions(+), 107 deletions(-) create mode 100644 public/app/features/dashboard/utils/getPanelChromeProps.tsx diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index fd1ceed7030..47d9f581ef9 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -6,6 +6,7 @@ import { Subscription } from 'rxjs'; import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { AngularComponent, getAngularLoader, locationService } from '@grafana/runtime'; +import { PanelChrome } from '@grafana/ui'; import config from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; import { setPanelAngularComponent } from 'app/features/panel/state/reducers'; @@ -15,8 +16,10 @@ import { StoreState } from 'app/types'; import { isSoloRoute } from '../../../routes/utils'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { DashboardModel, PanelModel } from '../state'; +import { getPanelChromeProps } from '../utils/getPanelChromeProps'; import { PanelHeader } from './PanelHeader/PanelHeader'; +import { PanelHeaderMenuWrapperNew } from './PanelHeader/PanelHeaderMenuWrapper'; interface OwnProps { panel: PanelModel; @@ -27,6 +30,7 @@ interface OwnProps { isInView: boolean; width: number; height: number; + hideMenu?: boolean; } interface ConnectedProps { @@ -58,7 +62,6 @@ export class PanelChromeAngularUnconnected extends PureComponent { timeSrv: TimeSrv = getTimeSrv(); scopeProps?: AngularScopeProps; subs = new Subscription(); - constructor(props: Props) { super(props); this.state = { @@ -179,9 +182,10 @@ export class PanelChromeAngularUnconnected extends PureComponent { const { dashboard, panel, isViewing, isEditing, plugin } = this.props; const { errorMessage, data } = this.state; const { transparent } = panel; - const alertState = data.alertState?.state; + const panelChromeProps = getPanelChromeProps({ ...this.props, data }); + const containerClassNames = classNames({ 'panel-container': true, 'panel-container--absolute': isSoloRoute(locationService.getLocation().pathname), @@ -196,29 +200,63 @@ export class PanelChromeAngularUnconnected extends PureComponent { 'panel-content--no-padding': plugin.noPadding, }); - return ( -
- -
-
(this.element = element)} className="panel-height-helper" /> + if (config.featureToggles.newPanelChromeUI) { + // Shift the hover menu down if it's on the top row so it doesn't get clipped by topnav + const hoverHeaderOffset = (panel.gridPos?.y ?? 0) === 0 ? -16 : undefined; + + const menu = ( +
+
-
- ); + ); + + return ( + + {() =>
(this.element = element)} className="panel-height-helper" />} + + ); + } else { + return ( +
+ +
+
(this.element = element)} className="panel-height-helper" /> +
+
+ ); + } } } diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 9d5f2f13b2d..a05518aacde 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -13,34 +13,29 @@ import { FieldConfigSource, getDataSourceRef, getDefaultTimeRange, - LinkModel, LoadingState, PanelData, PanelPlugin, PanelPluginMeta, PluginContextProvider, - renderMarkdown, TimeRange, toDataFrameDTO, toUtc, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { getTemplateSrv, config, locationService, RefreshEvent, reportInteraction } from '@grafana/runtime'; +import { config, locationService, RefreshEvent } from '@grafana/runtime'; import { VizLegendOptions } from '@grafana/schema'; import { ErrorBoundary, PanelChrome, PanelContext, PanelContextProvider, - PanelPadding, SeriesVisibilityChangeMode, AdHocFilterItem, } from '@grafana/ui'; import { PANEL_BORDER } from 'app/core/constants'; import { profiler } from 'app/core/profiler'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; -import { InspectTab } from 'app/features/inspector/types'; -import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { applyFilterFromTable } from 'app/features/variables/adhoc/actions'; import { onUpdatePanelSnapshotData } from 'app/plugins/datasource/grafana/utils'; @@ -53,11 +48,11 @@ import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annota import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { DashboardModel, PanelModel } from '../state'; +import { getPanelChromeProps } from '../utils/getPanelChromeProps'; import { loadSnapshotData } from '../utils/loadSnapshotData'; import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeaderMenuWrapperNew } from './PanelHeader/PanelHeaderMenuWrapper'; -import { PanelHeaderTitleItems } from './PanelHeader/PanelHeaderTitleItems'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { liveTimer } from './liveTimer'; @@ -91,7 +86,6 @@ export class PanelStateWrapper extends PureComponent { private readonly timeSrv: TimeSrv = getTimeSrv(); private subs = new Subscription(); private eventFilter: EventFilterOptions = { onlyLocal: true }; - private descriptionInteractionReported = false; constructor(props: Props) { super(props); @@ -605,53 +599,6 @@ export class PanelStateWrapper extends PureComponent { return !panel.hasTitle(); } - onShowPanelDescription = () => { - const { panel } = this.props; - const descriptionMarkdown = getTemplateSrv().replace(panel.description, panel.scopedVars); - const interpolatedDescription = renderMarkdown(descriptionMarkdown); - - if (!this.descriptionInteractionReported) { - // Description rendering function can be called multiple times due to re-renders but we want to report the interaction once. - reportInteraction('dashboards_panelheader_description_displayed'); - this.descriptionInteractionReported = true; - } - - return interpolatedDescription; - }; - - onShowPanelLinks = (): LinkModel[] => { - const { panel } = this.props; - const linkSupplier = getPanelLinksSupplier(panel); - if (linkSupplier) { - const panelLinks = linkSupplier && linkSupplier.getLinks(panel.replaceVariables); - - return panelLinks.map((panelLink) => ({ - ...panelLink, - onClick: (...args) => { - reportInteraction('dashboards_panelheader_datalink_clicked', { has_multiple_links: panelLinks.length > 1 }); - panelLink.onClick?.(...args); - }, - })); - } - return []; - }; - - onOpenInspector = (e: React.SyntheticEvent, tab: string) => { - e.stopPropagation(); - locationService.partial({ inspect: this.props.panel.id, inspectTab: tab }); - }; - - onOpenErrorInspect = (e: React.SyntheticEvent) => { - e.stopPropagation(); - locationService.partial({ inspect: this.props.panel.id, inspectTab: InspectTab.Error }); - reportInteraction('dashboards_panelheader_statusmessage_clicked'); - }; - - onCancelQuery = () => { - this.props.panel.getQueryRunner().cancelQuery(); - reportInteraction('dashboards_panelheader_cancelquery_clicked', { data_state: this.state.data.state }); - }; - render() { const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props; const { errorMessage, data } = this.state; @@ -668,27 +615,8 @@ export class PanelStateWrapper extends PureComponent { [`panel-alert-state--${alertState}`]: alertState !== undefined, }); - const title = panel.getDisplayTitle(); - const padding: PanelPadding = plugin.noPadding ? 'none' : 'md'; + const panelChromeProps = getPanelChromeProps({ ...this.props, data }); - const showTitleItems = - (panel.links && panel.links.length > 0 && this.onShowPanelLinks) || - (data.series.length > 0 && data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) || - (data.request && data.request.timeInfo) || - alertState; - - const titleItems = showTitleItems && ( - - ); - - const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : ''; if (config.featureToggles.newPanelChromeUI) { // Shift the hover menu down if it's on the top row so it doesn't get clipped by topnav const hoverHeaderOffset = (panel.gridPos?.y ?? 0) === 0 ? -16 : undefined; @@ -703,20 +631,20 @@ export class PanelStateWrapper extends PureComponent { {(innerWidth, innerHeight) => ( <> diff --git a/public/app/features/dashboard/utils/getPanelChromeProps.tsx b/public/app/features/dashboard/utils/getPanelChromeProps.tsx new file mode 100644 index 00000000000..fb4a68d5c99 --- /dev/null +++ b/public/app/features/dashboard/utils/getPanelChromeProps.tsx @@ -0,0 +1,120 @@ +import React from 'react'; + +import { LinkModel, PanelData, PanelPlugin, renderMarkdown } from '@grafana/data'; +import { getTemplateSrv, locationService, reportInteraction } from '@grafana/runtime'; +import { PanelPadding } from '@grafana/ui'; +import { InspectTab } from 'app/features/inspector/types'; +import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; + +import { PanelHeaderTitleItems } from '../dashgrid/PanelHeader/PanelHeaderTitleItems'; +import { DashboardModel, PanelModel } from '../state'; + +interface CommonProps { + panel: PanelModel; + data: PanelData; + dashboard: DashboardModel; + plugin: PanelPlugin; + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + width: number; + height: number; + hideMenu?: boolean; +} + +export function getPanelChromeProps(props: CommonProps) { + let descriptionInteractionReported = false; + + function hasOverlayHeader() { + // always show normal header if we have time override + if (props.data.request && props.data.request.timeInfo) { + return false; + } + + return !props.panel.hasTitle(); + } + + const onShowPanelDescription = () => { + const descriptionMarkdown = getTemplateSrv().replace(props.panel.description, props.panel.scopedVars); + const interpolatedDescription = renderMarkdown(descriptionMarkdown); + + if (!descriptionInteractionReported) { + // Description rendering function can be called multiple times due to re-renders but we want to report the interaction once. + reportInteraction('dashboards_panelheader_description_displayed'); + descriptionInteractionReported = true; + } + + return interpolatedDescription; + }; + + const onShowPanelLinks = (): LinkModel[] => { + const linkSupplier = getPanelLinksSupplier(props.panel); + if (!linkSupplier) { + return []; + } + const panelLinks = linkSupplier && linkSupplier.getLinks(props.panel.replaceVariables); + + return panelLinks.map((panelLink) => ({ + ...panelLink, + onClick: (...args) => { + reportInteraction('dashboards_panelheader_datalink_clicked', { has_multiple_links: panelLinks.length > 1 }); + panelLink.onClick?.(...args); + }, + })); + }; + + const onOpenInspector = (e: React.SyntheticEvent, tab: string) => { + e.stopPropagation(); + locationService.partial({ inspect: props.panel.id, inspectTab: tab }); + }; + + const onOpenErrorInspect = (e: React.SyntheticEvent) => { + e.stopPropagation(); + locationService.partial({ inspect: props.panel.id, inspectTab: InspectTab.Error }); + reportInteraction('dashboards_panelheader_statusmessage_clicked'); + }; + + const onCancelQuery = () => { + props.panel.getQueryRunner().cancelQuery(); + reportInteraction('dashboards_panelheader_cancelquery_clicked', { data_state: props.data.state }); + }; + + const padding: PanelPadding = props.plugin.noPadding ? 'none' : 'md'; + const alertState = props.data.alertState?.state; + + const showTitleItems = + (props.panel.links && props.panel.links.length > 0 && onShowPanelLinks) || + (props.data.series.length > 0 && props.data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) || + (props.data.request && props.data.request.timeInfo) || + alertState; + + const titleItems = showTitleItems && ( + + ); + + const description = props.panel.description ? onShowPanelDescription() : undefined; + + const dragClass = !(props.isViewing || props.isEditing) ? 'grid-drag-handle' : ''; + + const title = props.panel.getDisplayTitle(); + + return { + hasOverlayHeader, + onShowPanelDescription, + onShowPanelLinks, + onOpenInspector, + onOpenErrorInspect, + onCancelQuery, + padding, + description, + dragClass, + title, + titleItems, + }; +} From 1421f388aeda7e7c0d733184d9ec51e346843569 Mon Sep 17 00:00:00 2001 From: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Tue, 25 Apr 2023 13:05:37 -0400 Subject: [PATCH 401/729] Caching: Fix concurrent HTTP Header read/write in caching middleware (#67231) read the response header synchronously, defer the metric only --- .../clientmiddleware/caching_middleware.go | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go index e9e0b50bc0b..194b92832db 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go @@ -56,16 +56,16 @@ func (m *CachingMiddleware) QueryData(ctx context.Context, req *backend.QueryDat // First look in the query cache if enabled hit, cr := m.caching.HandleQueryRequest(ctx, req) - defer func() { - // record request duration if caching was used - if ch := reqCtx.Resp.Header().Get(caching.XCacheHeader); ch != "" { + // record request duration if caching was used + if ch := reqCtx.Resp.Header().Get(caching.XCacheHeader); ch != "" { + defer func() { QueryCachingRequestHistogram.With(prometheus.Labels{ "datasource_type": req.PluginContext.DataSourceInstanceSettings.Type, "cache": ch, "query_type": getQueryType(reqCtx), }).Observe(time.Since(start).Seconds()) - } - }() + }() + } // Cache hit; return the response if hit { @@ -102,15 +102,15 @@ func (m *CachingMiddleware) CallResource(ctx context.Context, req *backend.CallR // First look in the resource cache if enabled hit, cr := m.caching.HandleResourceRequest(ctx, req) - defer func() { - // record request duration if caching was used - if ch := reqCtx.Resp.Header().Get(caching.XCacheHeader); ch != "" { + // record request duration if caching was used + if ch := reqCtx.Resp.Header().Get(caching.XCacheHeader); ch != "" { + defer func() { ResourceCachingRequestHistogram.With(prometheus.Labels{ "plugin_id": req.PluginContext.PluginID, "cache": ch, }).Observe(time.Since(start).Seconds()) - } - }() + }() + } // Cache hit; send the response and return if hit { From 12e5101b91497dbc4bfaf30c41427eff2137a061 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Tue, 25 Apr 2023 12:31:45 -0500 Subject: [PATCH 402/729] Canvas: Connection properties based on data (#64360) Co-authored-by: nmarrs --- .betterer.results | 3 + public/app/features/canvas/element.ts | 4 +- public/app/features/canvas/runtime/scene.tsx | 2 + .../app/plugins/panel/canvas/CanvasPanel.tsx | 43 ++++++++- .../plugins/panel/canvas/ConnectionSVG.tsx | 96 ++++++++++++------- .../app/plugins/panel/canvas/Connections.tsx | 50 +++++++++- .../panel/canvas/editor/connectionEditor.tsx | 42 ++++++++ .../plugins/panel/canvas/editor/options.ts | 43 ++++++++- public/app/plugins/panel/canvas/module.tsx | 13 +++ public/app/plugins/panel/canvas/types.ts | 4 +- public/app/plugins/panel/canvas/utils.ts | 22 ++++- 11 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 public/app/plugins/panel/canvas/editor/connectionEditor.tsx diff --git a/.betterer.results b/.betterer.results index 34133989d8f..ae38d94c5e2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5193,6 +5193,9 @@ exports[`better eslint`] = { "public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/plugins/panel/canvas/editor/connectionEditor.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/plugins/panel/canvas/editor/elementEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index e2634ea9229..13a5358006b 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -4,7 +4,7 @@ import { RegistryItem } from '@grafana/data'; import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; import { config } from 'app/core/config'; -import { DimensionContext } from '../dimensions/context'; +import { DimensionContext, ColorDimensionConfig, ScaleDimensionConfig } from '../dimensions'; import { BackgroundConfig, Constraint, LineConfig, Placement } from './types'; @@ -46,6 +46,8 @@ export interface CanvasConnection { target: ConnectionCoordinates; targetName?: string; path: ConnectionPath; + color?: ColorDimensionConfig; + size?: ScaleDimensionConfig; // See https://github.com/anseki/leader-line#options for more examples of more properties } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 39034cdb554..d6a534cc67a 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -144,6 +144,8 @@ export class Scene { this.initMoveable(destroySelecto, enableEditing); this.currentLayer = this.root; this.selection.next([]); + this.connections.select(undefined); + this.connections.updateState(); } }); return this.root; diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 16d86b6c6de..0b80d268ae7 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -12,7 +12,7 @@ import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; import { InlineEdit } from './InlineEdit'; import { SetBackground } from './SetBackground'; import { PanelOptions } from './models.gen'; -import { AnchorPoint, CanvasTooltipPayload } from './types'; +import { AnchorPoint, CanvasTooltipPayload, ConnectionState } from './types'; interface Props extends PanelProps {} @@ -27,6 +27,7 @@ interface State { export interface InstanceState { scene: Scene; selected: ElementState[]; + selectedConnection?: ConnectionState; } export interface SelectionAction { @@ -113,20 +114,56 @@ export class CanvasPanel extends Component { this.subs.add( this.scene.selection.subscribe({ next: (v) => { + if (v.length) { + activeCanvasPanel = this; + activePanelSubject.next({ panel: this }); + } + + canvasInstances.forEach((canvasInstance) => { + if (canvasInstance !== activeCanvasPanel) { + canvasInstance.scene.clearCurrentSelection(true); + canvasInstance.scene.connections.select(undefined); + } + }); + this.panelContext.onInstanceStateChange!({ scene: this.scene, selected: v, layer: this.scene.root, }); + }, + }) + ); - activeCanvasPanel = this; - activePanelSubject.next({ panel: this }); + this.subs.add( + this.scene.connections.selection.subscribe({ + next: (v) => { + if (!this.context.instanceState) { + return; + } + + this.panelContext.onInstanceStateChange!({ + scene: this.scene, + selected: this.context.instanceState.selected, + selectedConnection: v, + layer: this.scene.root, + }); + + if (v) { + activeCanvasPanel = this; + activePanelSubject.next({ panel: this }); + } canvasInstances.forEach((canvasInstance) => { if (canvasInstance !== activeCanvasPanel) { canvasInstance.scene.clearCurrentSelection(true); + canvasInstance.scene.connections.select(undefined); } }); + + setTimeout(() => { + this.forceUpdate(); + }); }, }) ); diff --git a/public/app/plugins/panel/canvas/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/ConnectionSVG.tsx index 63e98a7bc88..e2146895cfd 100644 --- a/public/app/plugins/panel/canvas/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/ConnectionSVG.tsx @@ -1,14 +1,12 @@ import { css } from '@emotion/css'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { config } from 'app/core/config'; -import { CanvasConnection } from 'app/features/canvas/element'; -import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; -import { getConnections } from './utils'; +import { ConnectionState } from './types'; type Props = { setSVGRef: (anchorElement: SVGSVGElement) => void; @@ -17,16 +15,18 @@ type Props = { }; let idCounter = 0; +const htmlElementTypes = ['input', 'textarea']; + export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const styles = useStyles2(getStyles); const headId = Date.now() + '_' + idCounter++; - const CONNECTION_LINE_ID = 'connectionLineId'; - const CONNECTION_HEAD_ID = useMemo(() => `head-${headId}`, [headId]); + const CONNECTION_LINE_ID = useMemo(() => `connectionLineId-${headId}`, [headId]); const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]); const defaultArrowColor = config.theme2.colors.text.primary; + const defaultArrowSize = 2; - const [selectedConnection, setSelectedConnection] = useState(undefined); + const [selectedConnection, setSelectedConnection] = useState(undefined); // Need to use ref to ensure state is not stale in event handler const selectedConnectionRef = useRef(selectedConnection); @@ -34,24 +34,36 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { selectedConnectionRef.current = selectedConnection; }); - const [selectedConnectionSource, setSelectedConnectionSource] = useState(undefined); - const selectedConnectionSourceRef = useRef(selectedConnectionSource); useEffect(() => { - selectedConnectionSourceRef.current = selectedConnectionSource; - }); + if (scene.panel.context.instanceState?.selectedConnection) { + setSelectedConnection(scene.panel.context.instanceState?.selectedConnection); + } + }, [scene.panel.context.instanceState?.selectedConnection]); const onKeyUp = (e: KeyboardEvent) => { + const target = e.target; + + if (!(target instanceof HTMLElement)) { + return; + } + + if (htmlElementTypes.indexOf(target.nodeName.toLowerCase()) > -1) { + return; + } + // Backspace (8) or delete (46) if (e.keyCode === 8 || e.keyCode === 46) { - if (selectedConnectionRef.current && selectedConnectionSourceRef.current) { - selectedConnectionSourceRef.current.options.connections = - selectedConnectionSourceRef.current.options.connections?.filter( - (connection) => connection !== selectedConnectionRef.current + if (selectedConnectionRef.current && selectedConnectionRef.current.source) { + selectedConnectionRef.current.source.options.connections = + selectedConnectionRef.current.source.options.connections?.filter( + (connection) => connection !== selectedConnectionRef.current?.info ); - selectedConnectionSourceRef.current.onChange(selectedConnectionSourceRef.current.options); + selectedConnectionRef.current.source.onChange(selectedConnectionRef.current.source.options); setSelectedConnection(undefined); - setSelectedConnectionSource(undefined); + scene.connections.select(undefined); + scene.connections.updateState(); + scene.save(); } } else { // Prevent removing event listener if key is not delete @@ -71,28 +83,36 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { if (shouldResetSelectedConnection) { setSelectedConnection(undefined); - setSelectedConnectionSource(undefined); + scene.connections.select(undefined); } }; - const selectConnection = (connection: CanvasConnection, source: ElementState) => { + const selectConnection = (connection: ConnectionState) => { if (scene.isEditingEnabled) { setSelectedConnection(connection); - setSelectedConnectionSource(source); + scene.connections.select(connection); document.addEventListener('keyup', onKeyUp); scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection); } }; - // Flat list of all connections - const findConnections = useCallback(() => { - return getConnections(scene.byName); - }, [scene.byName]); + // @TODO revisit, currently returning last row index for field + const getRowIndex = (fieldName: string | undefined) => { + if (fieldName) { + const series = scene.context.getPanelData()?.series[0]; + const field = series?.fields.find((f) => (f.name = fieldName)); + const data = field?.values; + + return data ? data.length - 1 : 0; + } + + return 0; + }; // Figure out target and then target's relative coordinates drawing (if no target do parent) const renderConnections = () => { - return findConnections().map((v, idx) => { + return scene.connections.state.map((v, idx) => { const { source, target, info } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; @@ -129,13 +149,21 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2; } - const isSelected = selectedConnection === info; - const selectedStyles = { stroke: '#44aaff', strokeWidth: 3 }; + const isSelected = selectedConnection === v && scene.panel.context.instanceState.selectedConnection; + + const strokeColor = info.color ? scene.context.getColor(info.color).value() : defaultArrowColor; + const lastRowIndex = getRowIndex(info.size?.field); + + const strokeWidth = info.size ? scene.context.getScale(info.size).get(lastRowIndex) : defaultArrowSize; + const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : ''; + const selectedStyles = { stroke: '#44aaff', strokeOpacity: 0.6, strokeWidth: strokeWidth + 5 }; + + const CONNECTION_HEAD_ID = `connectionHead-${headId + Math.random()}`; return ( - selectConnection(info, source)}> + selectConnection(v)}> { refX="10" refY="3.5" orient="auto" - stroke={defaultArrowColor} + stroke={strokeColor} > - + { /> diff --git a/public/app/plugins/panel/canvas/Connections.tsx b/public/app/plugins/panel/canvas/Connections.tsx index fa106ad31e1..6edda793f76 100644 --- a/public/app/plugins/panel/canvas/Connections.tsx +++ b/public/app/plugins/panel/canvas/Connections.tsx @@ -1,12 +1,15 @@ import React from 'react'; +import { BehaviorSubject } from 'rxjs'; -import { ConnectionPath } from 'app/features/canvas'; +import { config } from '@grafana/runtime'; +import { CanvasConnection, ConnectionPath } from 'app/features/canvas'; import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors'; import { ConnectionSVG } from './ConnectionSVG'; -import { isConnectionSource, isConnectionTarget } from './utils'; +import { ConnectionState } from './types'; +import { getConnections, isConnectionSource, isConnectionTarget } from './utils'; export class Connections { scene: Scene; @@ -17,11 +20,35 @@ export class Connections { connectionTarget?: ElementState; isDrawingConnection?: boolean; didConnectionLeaveHighlight?: boolean; + state: ConnectionState[] = []; + readonly selection = new BehaviorSubject(undefined); constructor(scene: Scene) { this.scene = scene; + this.updateState(); } + select = (connection: ConnectionState | undefined) => { + if (connection === this.selection.value) { + return; + } + this.selection.next(connection); + }; + + updateState = () => { + const s = this.selection.value; + this.state = getConnections(this.scene.byName); + + if (s) { + for (let c of this.state) { + if (c.source === s.source && c.index === s.index) { + this.selection.next(c); + break; + } + } + } + }; + setConnectionAnchorRef = (anchorElement: HTMLDivElement) => { this.connectionAnchorDiv = anchorElement; }; @@ -174,8 +201,14 @@ export class Connections { y: targetY, }, targetName: targetName, - color: 'white', - size: 10, + color: { + fixed: config.theme2.colors.text.primary, + }, + size: { + fixed: 2, + min: 1, + max: 10, + }, path: ConnectionPath.Straight, }; @@ -199,6 +232,8 @@ export class Connections { } this.isDrawingConnection = false; + this.updateState(); + this.scene.save(); } }; @@ -224,6 +259,13 @@ export class Connections { this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener); }; + onChange = (current: ConnectionState, update: CanvasConnection) => { + const connections = current.source.options.connections?.splice(0) ?? []; + connections[current.index] = update; + current.source.onChange({ ...current.source.options, connections }); + this.updateState(); + }; + // used for moveable actions connectionsNeedUpdate = (element: ElementState): boolean => { return isConnectionSource(element) || isConnectionTarget(element, this.scene.byName); diff --git a/public/app/plugins/panel/canvas/editor/connectionEditor.tsx b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx new file mode 100644 index 00000000000..49689915cac --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx @@ -0,0 +1,42 @@ +import { get as lodashGet } from 'lodash'; + +import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; +import { CanvasConnection } from 'app/features/canvas'; +import { Scene } from 'app/features/canvas/runtime/scene'; +import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; + +import { ConnectionState } from '../types'; + +import { optionBuilder } from './options'; + +export interface CanvasConnectionEditorOptions { + connection: ConnectionState; + scene: Scene; + category?: string[]; +} + +export function getConnectionEditor(opts: CanvasConnectionEditorOptions): NestedPanelOptions { + return { + category: opts.category, + path: '--', // not used! + + values: (parent: NestedValueAccess) => ({ + getValue: (path: string) => { + return lodashGet(opts.connection.info, path); + }, + // TODO: Fix this any (maybe a dimension supplier?) + onChange: (path: string, value: any) => { + console.log(value, typeof value); + let options = opts.connection.info; + options = setOptionImmutably(options, path, value); + opts.scene.connections.onChange(opts.connection, options); + }, + }), + + build: (builder, context) => { + const ctx = { ...context, options: opts.connection.info }; + optionBuilder.addColor(builder, ctx); + optionBuilder.addSize(builder, ctx); + }, + }; +} diff --git a/public/app/plugins/panel/canvas/editor/options.ts b/public/app/plugins/panel/canvas/editor/options.ts index 2e81e17b821..12858d18c7b 100644 --- a/public/app/plugins/panel/canvas/editor/options.ts +++ b/public/app/plugins/panel/canvas/editor/options.ts @@ -1,11 +1,13 @@ import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; -import { CanvasElementOptions } from 'app/features/canvas'; -import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors'; +import { CanvasConnection, CanvasElementOptions } from 'app/features/canvas'; +import { ColorDimensionEditor, ResourceDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors'; import { BackgroundSizeEditor } from 'app/features/dimensions/editors/BackgroundSizeEditor'; interface OptionSuppliers { addBackground: PanelOptionsSupplier; addBorder: PanelOptionsSupplier; + addColor: PanelOptionsSupplier; + addSize: PanelOptionsSupplier; } const getCategoryName = (str: string, type: string | undefined) => { @@ -81,4 +83,41 @@ export const optionBuilder: OptionSuppliers = { }); } }, + + addColor: (builder, context) => { + const category = ['Color']; + builder.addCustomEditor({ + category, + id: 'color', + path: 'color', + name: 'Color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: { + // Configured values + fixed: '', + }, + }); + }, + + addSize: (builder, context) => { + const category = ['Size']; + builder.addCustomEditor({ + category, + id: 'size', + path: 'size', + name: 'Size', + editor: ScaleDimensionEditor, + settings: { + min: 1, + max: 10, + }, + defaultValue: { + // Configured values + fixed: 2, + min: 1, + max: 10, + }, + }); + }, }; diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx index 092afb38e0a..5e3fe07af0e 100644 --- a/public/app/plugins/panel/canvas/module.tsx +++ b/public/app/plugins/panel/canvas/module.tsx @@ -2,6 +2,7 @@ import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@gr import { FrameState } from 'app/features/canvas/runtime/frame'; import { CanvasPanel, InstanceState } from './CanvasPanel'; +import { getConnectionEditor } from './editor/connectionEditor'; import { getElementEditor } from './editor/elementEditor'; import { getLayerEditor } from './editor/layerEditor'; import { canvasMigrationHandler } from './migrations'; @@ -44,6 +45,8 @@ export const plugin = new PanelPlugin(CanvasPanel) builder.addNestedOptions(getLayerEditor(state)); const selection = state.selected; + const connectionSelection = state.selectedConnection; + if (selection?.length === 1) { const element = selection[0]; if (!(element instanceof FrameState)) { @@ -56,5 +59,15 @@ export const plugin = new PanelPlugin(CanvasPanel) ); } } + + if (connectionSelection) { + builder.addNestedOptions( + getConnectionEditor({ + category: ['Selected connection'], + connection: connectionSelection, + scene: state.scene, + }) + ); + } } }); diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts index 1e7b5958baf..aea23f571af 100644 --- a/public/app/plugins/panel/canvas/types.ts +++ b/public/app/plugins/panel/canvas/types.ts @@ -20,6 +20,7 @@ export interface DropNode extends DragNode { export enum InlineEditTabs { ElementManagement = 'element-management', SelectedElement = 'selected-element', + SelectedConnection = 'selected-connection', } export type AnchorPoint = { @@ -33,7 +34,8 @@ export interface CanvasTooltipPayload { isOpen?: boolean; } -export interface ConnectionInfo { +export interface ConnectionState { + index: number; // array index from the source source: ElementState; target: ElementState; info: CanvasConnection; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 4422882e97f..26fac0ec0a6 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -1,3 +1,5 @@ +import { isNumber, isString } from 'lodash'; + import { AppEvents, Field, LinkModel, PluginState, SelectableValue } from '@grafana/data'; import { hasAlphaPanels } from 'app/core/config'; @@ -16,7 +18,7 @@ import { FrameState } from '../../../features/canvas/runtime/frame'; import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene'; import { DimensionContext } from '../../../features/dimensions'; -import { AnchorPoint, ConnectionInfo } from './types'; +import { AnchorPoint, ConnectionState } from './types'; export function doSelect(scene: Scene, element: ElementState | FrameState) { try { @@ -139,19 +141,29 @@ export function isConnectionTarget(element: ElementState, sceneByName: Map) { - const connections: ConnectionInfo[] = []; + const connections: ConnectionState[] = []; for (let v of sceneByName.values()) { if (v.options.connections) { - for (let c of v.options.connections) { + v.options.connections.forEach((c, index) => { + // @TODO Remove after v10.x + if (isString(c.color)) { + c.color = { fixed: c.color }; + } + + if (isNumber(c.size)) { + c.size = { fixed: 2, min: 1, max: 10 }; + } + const target = c.targetName ? sceneByName.get(c.targetName) : v.parent; if (target) { connections.push({ + index, source: v, target, info: c, }); } - } + }); } } @@ -159,7 +171,7 @@ export function getConnections(sceneByName: Map) { } export function getConnectionsByTarget(element: ElementState, scene: Scene) { - return getConnections(scene.byName).filter((connection) => connection.target === element); + return scene.connections.state.filter((connection) => connection.target === element); } export function updateConnectionsForSource(element: ElementState, scene: Scene) { From a8b4a4bb45ae5b595f8016ec554a99c546970e5f Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Tue, 25 Apr 2023 13:39:46 -0400 Subject: [PATCH 403/729] Alerting: Update alerting module to 20230418161049-5f374e58cb32 + refactoring (#66622) * update to alerting 20230418161049-5f374e58cb32 * rename renamed structs in https://github.com/grafana/alerting/pull/73 * update ValidateContactPoint to use BuildReceiverConfiguration * update logger factory according to changes * rewrite integration builder Co-authored-by: Santiago --- go.mod | 2 +- go.sum | 4 + pkg/services/ngalert/api/api_alertmanager.go | 4 +- .../ngalert/api/api_alertmanager_test.go | 12 +-- pkg/services/ngalert/notifier/alertmanager.go | 94 ++++++++----------- pkg/services/ngalert/notifier/compat.go | 16 ++-- pkg/services/ngalert/notifier/compat_test.go | 14 +-- pkg/services/ngalert/notifier/config.go | 15 +-- pkg/services/ngalert/notifier/email_test.go | 43 +++------ pkg/services/ngalert/notifier/log.go | 4 +- .../ngalert/notifier/multiorg_alertmanager.go | 5 +- pkg/services/ngalert/notifier/receivers.go | 8 +- .../ngalert/notifier/receivers_test.go | 35 +++---- pkg/services/ngalert/notifier/sender.go | 4 - pkg/services/ngalert/provisioning/compat.go | 22 +++++ .../ngalert/provisioning/contactpoints.go | 28 ++---- .../alerting/contact_point_types.go | 2 +- .../sqlstore/migrations/ualert/ualert.go | 4 +- .../sqlstore/migrations/ualert/ualert_test.go | 2 +- .../api_alertmanager_configuration_test.go | 2 +- .../alerting/api_notification_channel_test.go | 51 +++++----- pkg/tests/api/alerting/testing.go | 6 +- 22 files changed, 179 insertions(+), 198 deletions(-) create mode 100644 pkg/services/ngalert/provisioning/compat.go diff --git a/go.mod b/go.mod index dea70c0cdda..355a0e5944f 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.5.0 - github.com/grafana/alerting v0.0.0-20230315185333-d1e3c68ac064 + github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32 github.com/grafana/grafana-aws-sdk v0.12.0 github.com/grafana/grafana-azure-sdk-go v1.6.0 github.com/grafana/grafana-plugin-sdk-go v0.159.0 diff --git a/go.sum b/go.sum index 01a5de63f60..e9f1b8a0af6 100644 --- a/go.sum +++ b/go.sum @@ -1272,6 +1272,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20230315185333-d1e3c68ac064 h1:MtsWzSTav7NGKolO+TaJQUcyR7VY0YpUROVsJX8ktIU= github.com/grafana/alerting v0.0.0-20230315185333-d1e3c68ac064/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= +github.com/grafana/alerting v0.0.0-20230410151633-4a7ecc241d72 h1:WuQGIUeDIyPviylMaMD1d2nEYIiD/icHYO0rc/AH8kQ= +github.com/grafana/alerting v0.0.0-20230410151633-4a7ecc241d72/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= +github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32 h1:LdPoVBj+CA5oHLeUejDzqy8/c4Fa0UfTtCcOHka0Jws= +github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= 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.8 h1:l0AKXfHr0clu6qPirirDzNC/W5mqq5gG7iruOVolG34= diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 88dd9eb96f9..3c3c18b6fd0 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -406,8 +406,8 @@ func statusForTestReceivers(v []notifier.TestReceiverResult) int { for _, next := range receiver.Configs { if next.Error != nil { var ( - invalidReceiverErr alertingNotify.InvalidReceiverError - receiverTimeoutErr alertingNotify.ReceiverTimeoutError + invalidReceiverErr alertingNotify.IntegrationValidationError + receiverTimeoutErr alertingNotify.IntegrationTimeoutError ) if errors.As(next.Error, &invalidReceiverErr) { numBadRequests += 1 diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 5fed1c2c87f..94c29dfbf2d 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -108,7 +108,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test1", UID: "uid1", Status: "failed", - Error: alertingNotify.InvalidReceiverError{}, + Error: alertingNotify.IntegrationValidationError{}, }}, }, { Name: "test2", @@ -116,7 +116,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test2", UID: "uid2", Status: "failed", - Error: alertingNotify.InvalidReceiverError{}, + Error: alertingNotify.IntegrationValidationError{}, }}, }})) }) @@ -128,7 +128,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test1", UID: "uid1", Status: "failed", - Error: alertingNotify.ReceiverTimeoutError{}, + Error: alertingNotify.IntegrationTimeoutError{}, }}, }, { Name: "test2", @@ -136,7 +136,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test2", UID: "uid2", Status: "failed", - Error: alertingNotify.ReceiverTimeoutError{}, + Error: alertingNotify.IntegrationTimeoutError{}, }}, }})) }) @@ -148,7 +148,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test1", UID: "uid1", Status: "failed", - Error: alertingNotify.InvalidReceiverError{}, + Error: alertingNotify.IntegrationValidationError{}, }}, }, { Name: "test2", @@ -156,7 +156,7 @@ func TestStatusForTestReceivers(t *testing.T) { Name: "test2", UID: "uid2", Status: "failed", - Error: alertingNotify.ReceiverTimeoutError{}, + Error: alertingNotify.IntegrationTimeoutError{}, }}, }})) }) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index 1f26f219888..e5304520110 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -3,9 +3,7 @@ package notifier import ( "context" "crypto/md5" - "encoding/base64" "encoding/json" - "errors" "fmt" "path/filepath" "strconv" @@ -54,7 +52,7 @@ type Alertmanager struct { fileStore *FileStore NotificationService notifications.Service - decryptFn receivers.GetDecryptedValueFn + decryptFn alertingNotify.GetDecryptedValueFn orgID int64 } @@ -84,7 +82,7 @@ func (m maintenanceOptions) MaintenanceFunc(state alertingNotify.State) (int64, } func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store AlertingStore, kvStore kvstore.KVStore, - peer alertingNotify.ClusterPeer, decryptFn receivers.GetDecryptedValueFn, ns notifications.Service, + peer alertingNotify.ClusterPeer, decryptFn alertingNotify.GetDecryptedValueFn, ns notifications.Service, m *metrics.Alertmanager) (*Alertmanager, error) { workingPath := filepath.Join(cfg.DataPath, workingDir, strconv.Itoa(int(orgID))) fileStore := NewFileStore(orgID, kvStore, workingPath) @@ -317,7 +315,7 @@ func (am *Alertmanager) WorkingDirPath() string { } // buildIntegrationsMap builds a map of name to the list of Grafana integration notifiers off of a list of receiver config. -func (am *Alertmanager) buildIntegrationsMap(receivers []*apimodels.PostableApiReceiver, templates *alertingNotify.Template) (map[string][]*alertingNotify.Integration, error) { +func (am *Alertmanager) buildIntegrationsMap(receivers []*alertingNotify.APIReceiver, templates *alertingTemplates.Template) (map[string][]*alertingNotify.Integration, error) { integrationsMap := make(map[string][]*alertingNotify.Integration, len(receivers)) for _, receiver := range receivers { integrations, err := am.buildReceiverIntegrations(receiver, templates) @@ -331,66 +329,48 @@ func (am *Alertmanager) buildIntegrationsMap(receivers []*apimodels.PostableApiR } // buildReceiverIntegrations builds a list of integration notifiers off of a receiver config. -func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableApiReceiver, tmpl *alertingNotify.Template) ([]*alertingNotify.Integration, error) { - integrations := make([]*alertingNotify.Integration, 0, len(receiver.GrafanaManagedReceivers)) - for i, r := range receiver.GrafanaManagedReceivers { - n, err := am.buildReceiverIntegration(PostableGrafanaReceiverToGrafanaReceiver(r), tmpl) - if err != nil { - return nil, err - } - integrations = append(integrations, alertingNotify.NewIntegration(n, n, r.Type, i)) +func (am *Alertmanager) buildReceiverIntegrations(receiver *alertingNotify.APIReceiver, tmpl *alertingTemplates.Template) ([]*alertingNotify.Integration, error) { + receiverCfg, err := alertingNotify.BuildReceiverConfiguration(context.Background(), receiver, am.decryptFn) + if err != nil { + return nil, err + } + s := &sender{am.NotificationService} + img := newImageStore(am.Store) + integrations, err := alertingNotify.BuildReceiverIntegrations( + receiverCfg, + tmpl, + img, + LoggerFactory, + func(n receivers.Metadata) (receivers.WebhookSender, error) { + return s, nil + }, + func(n receivers.Metadata) (receivers.EmailSender, error) { + return s, nil + }, + am.orgID, + setting.BuildVersion, + ) + if err != nil { + return nil, err } return integrations, nil } -func (am *Alertmanager) buildReceiverIntegration(r *alertingNotify.GrafanaReceiver, tmpl *alertingNotify.Template) (alertingNotify.NotificationChannel, error) { - // secure settings are already encrypted at this point - secureSettings := make(map[string][]byte, len(r.SecureSettings)) - - for k, v := range r.SecureSettings { - d, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return nil, alertingNotify.InvalidReceiverError{ - Receiver: r, - Err: errors.New("failed to decode secure setting"), - } - } - secureSettings[k] = d +func (am *Alertmanager) buildReceiverIntegration(r *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (*alertingNotify.Integration, error) { + apiReceiver := &alertingNotify.APIReceiver{ + GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ + Integrations: []*alertingNotify.GrafanaIntegrationConfig{r}, + }, } - - var ( - cfg = &receivers.NotificationChannelConfig{ - UID: r.UID, - OrgID: am.orgID, - Name: r.Name, - Type: r.Type, - DisableResolveMessage: r.DisableResolveMessage, - Settings: r.Settings, - SecureSettings: secureSettings, - } - ) - factoryConfig, err := receivers.NewFactoryConfig(cfg, NewNotificationSender(am.NotificationService), am.decryptFn, tmpl, newImageStore(am.Store), LoggerFactory, setting.BuildVersion) + integrations, err := am.buildReceiverIntegrations(apiReceiver, tmpl) if err != nil { - return nil, alertingNotify.InvalidReceiverError{ - Receiver: r, - Err: err, - } + return nil, err } - receiverFactory, exists := alertingNotify.Factory(r.Type) - if !exists { - return nil, alertingNotify.InvalidReceiverError{ - Receiver: r, - Err: fmt.Errorf("notifier %s is not supported", r.Type), - } + if len(integrations) == 0 { + // This should not happen, but it is better to return some error rather than having a panic. + return nil, fmt.Errorf("failed to build integration") } - n, err := receiverFactory(factoryConfig) - if err != nil { - return nil, alertingNotify.InvalidReceiverError{ - Receiver: r, - Err: err, - } - } - return n, nil + return integrations[0], nil } // PutAlerts receives the alerts and then sends them through the corresponding route based on whenever the alert has a receiver embedded or not diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index 90c4807d8f3..41bcb1cab87 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -8,8 +8,8 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) -func PostableGrafanaReceiverToGrafanaReceiver(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaReceiver { - return &alertingNotify.GrafanaReceiver{ +func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig { + return &alertingNotify.GrafanaIntegrationConfig{ UID: p.UID, Name: p.Name, Type: p.Type, @@ -20,16 +20,16 @@ func PostableGrafanaReceiverToGrafanaReceiver(p *apimodels.PostableGrafanaReceiv } func PostableApiReceiverToApiReceiver(r *apimodels.PostableApiReceiver) *alertingNotify.APIReceiver { - receivers := alertingNotify.GrafanaReceivers{ - Receivers: make([]*alertingNotify.GrafanaReceiver, 0, len(r.GrafanaManagedReceivers)), + integrations := alertingNotify.GrafanaIntegrations{ + Integrations: make([]*alertingNotify.GrafanaIntegrationConfig, 0, len(r.GrafanaManagedReceivers)), } - for _, receiver := range r.GrafanaManagedReceivers { - receivers.Receivers = append(receivers.Receivers, PostableGrafanaReceiverToGrafanaReceiver(receiver)) + for _, cfg := range r.GrafanaManagedReceivers { + integrations.Integrations = append(integrations.Integrations, PostableGrafanaReceiverToGrafanaIntegrationConfig(cfg)) } return &alertingNotify.APIReceiver{ - ConfigReceiver: r.Receiver, - GrafanaReceivers: receivers, + ConfigReceiver: r.Receiver, + GrafanaIntegrations: integrations, } } diff --git a/pkg/services/ngalert/notifier/compat_test.go b/pkg/services/ngalert/notifier/compat_test.go index 46d95b37405..b3a2ff8508a 100644 --- a/pkg/services/ngalert/notifier/compat_test.go +++ b/pkg/services/ngalert/notifier/compat_test.go @@ -11,7 +11,7 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) -func TestPostableGrafanaReceiverToGrafanaReceiver(t *testing.T) { +func TestPostableGrafanaReceiverToGrafanaIntegrationConfig(t *testing.T) { r := &apimodels.PostableGrafanaReceiver{ UID: "test-uid", Name: "test-name", @@ -22,8 +22,8 @@ func TestPostableGrafanaReceiverToGrafanaReceiver(t *testing.T) { "test": "data", }, } - actual := PostableGrafanaReceiverToGrafanaReceiver(r) - require.Equal(t, alertingNotify.GrafanaReceiver{ + actual := PostableGrafanaReceiverToGrafanaIntegrationConfig(r) + require.Equal(t, alertingNotify.GrafanaIntegrationConfig{ UID: "test-uid", Name: "test-name", Type: "slack", @@ -43,7 +43,7 @@ func TestPostableApiReceiverToApiReceiver(t *testing.T) { }, } actual := PostableApiReceiverToApiReceiver(r) - require.Empty(t, actual.Receivers) + require.Empty(t, actual.Integrations) require.Equal(t, r.Receiver, actual.ConfigReceiver) }) t.Run("converts receivers", func(t *testing.T) { @@ -77,10 +77,10 @@ func TestPostableApiReceiverToApiReceiver(t *testing.T) { }, } actual := PostableApiReceiverToApiReceiver(r) - require.Len(t, actual.Receivers, 2) + require.Len(t, actual.Integrations, 2) require.Equal(t, r.Receiver, actual.ConfigReceiver) - require.Equal(t, *PostableGrafanaReceiverToGrafanaReceiver(r.GrafanaManagedReceivers[0]), *actual.Receivers[0]) - require.Equal(t, *PostableGrafanaReceiverToGrafanaReceiver(r.GrafanaManagedReceivers[1]), *actual.Receivers[1]) + require.Equal(t, *PostableGrafanaReceiverToGrafanaIntegrationConfig(r.GrafanaManagedReceivers[0]), *actual.Integrations[0]) + require.Equal(t, *PostableGrafanaReceiverToGrafanaIntegrationConfig(r.GrafanaManagedReceivers[1]), *actual.Integrations[1]) }) } diff --git a/pkg/services/ngalert/notifier/config.go b/pkg/services/ngalert/notifier/config.go index 01ecab34017..5acb73c390d 100644 --- a/pkg/services/ngalert/notifier/config.go +++ b/pkg/services/ngalert/notifier/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" alertingNotify "github.com/grafana/alerting/notify" + alertingTemplates "github.com/grafana/alerting/templates" "github.com/grafana/grafana/pkg/infra/log" api "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -94,14 +95,14 @@ type AlertingConfiguration struct { AlertmanagerConfig api.PostableApiAlertingConfig RawAlertmanagerConfig []byte - AlertmanagerTemplates *alertingNotify.Template + AlertmanagerTemplates *alertingTemplates.Template - IntegrationsFunc func(receivers []*api.PostableApiReceiver, templates *alertingNotify.Template) (map[string][]*alertingNotify.Integration, error) - ReceiverIntegrationsFunc func(r *alertingNotify.GrafanaReceiver, tmpl *alertingNotify.Template) (alertingNotify.NotificationChannel, error) + IntegrationsFunc func(receivers []*alertingNotify.APIReceiver, templates *alertingTemplates.Template) (map[string][]*alertingNotify.Integration, error) + ReceiverIntegrationsFunc func(r *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (*alertingNotify.Integration, error) } -func (a AlertingConfiguration) BuildReceiverIntegrationsFunc() func(next *alertingNotify.GrafanaReceiver, tmpl *alertingNotify.Template) (alertingNotify.Notifier, error) { - return func(next *alertingNotify.GrafanaReceiver, tmpl *alertingNotify.Template) (alertingNotify.Notifier, error) { +func (a AlertingConfiguration) BuildReceiverIntegrationsFunc() func(next *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (alertingNotify.Notifier, error) { + return func(next *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (alertingNotify.Notifier, error) { return a.ReceiverIntegrationsFunc(next, tmpl) } } @@ -119,14 +120,14 @@ func (a AlertingConfiguration) MuteTimeIntervals() []alertingNotify.MuteTimeInte } func (a AlertingConfiguration) ReceiverIntegrations() (map[string][]*alertingNotify.Integration, error) { - return a.IntegrationsFunc(a.AlertmanagerConfig.Receivers, a.AlertmanagerTemplates) + return a.IntegrationsFunc(PostableApiAlertingConfigToApiReceivers(a.AlertmanagerConfig), a.AlertmanagerTemplates) } func (a AlertingConfiguration) RoutingTree() *alertingNotify.Route { return a.AlertmanagerConfig.Route.AsAMRoute() } -func (a AlertingConfiguration) Templates() *alertingNotify.Template { +func (a AlertingConfiguration) Templates() *alertingTemplates.Template { return a.AlertmanagerTemplates } diff --git a/pkg/services/ngalert/notifier/email_test.go b/pkg/services/ngalert/notifier/email_test.go index 7bcd66a10a2..1fe36a4a3bc 100644 --- a/pkg/services/ngalert/notifier/email_test.go +++ b/pkg/services/ngalert/notifier/email_test.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "encoding/json" "net/url" "os" "testing" @@ -188,40 +187,20 @@ func TestEmailNotifierIntegration(t *testing.T) { } } -func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *template.Template, ns receivers.NotificationSender) *alertingEmail.Notifier { +func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *template.Template, ns receivers.EmailSender) *alertingEmail.Notifier { t.Helper() - - jsonData := map[string]interface{}{ - "addresses": "someops@example.com;somedev@example.com", - "singleEmail": true, + if subjectTmpl == "" { + subjectTmpl = alertingTemplates.DefaultMessageTitleEmbed } - if messageTmpl != "" { - jsonData["message"] = messageTmpl - } - - if subjectTmpl != "" { - jsonData["subject"] = subjectTmpl - } - bytes, err := json.Marshal(jsonData) - require.NoError(t, err) - - fc := receivers.FactoryConfig{ - Config: &receivers.NotificationChannelConfig{ - Name: "ops", - Type: "alertingEmail", - Settings: json.RawMessage(bytes), + return alertingEmail.New(alertingEmail.Config{ + SingleEmail: true, + Addresses: []string{ + "someops@example.com", + "somedev@example.com", }, - NotificationService: ns, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: &images.UnavailableImageStore{}, - Template: emailTmpl, - Logger: &alertingLogging.FakeLogger{}, - } - emailNotifier, err := alertingEmail.New(fc) - require.NoError(t, err) - return emailNotifier + Message: messageTmpl, + Subject: subjectTmpl, + }, receivers.Metadata{}, emailTmpl, ns, &images.UnavailableImageStore{}, &alertingLogging.FakeLogger{}) } func getSingleSentMessage(t *testing.T, ns *emailSender) *notifications.Message { diff --git a/pkg/services/ngalert/notifier/log.go b/pkg/services/ngalert/notifier/log.go index a48477d8abe..aed0addd87f 100644 --- a/pkg/services/ngalert/notifier/log.go +++ b/pkg/services/ngalert/notifier/log.go @@ -6,8 +6,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) -var LoggerFactory alertingLogging.LoggerFactory = func(ctx ...interface{}) alertingLogging.Logger { - return &logWrapper{log.New(ctx...)} +var LoggerFactory alertingLogging.LoggerFactory = func(logger string, ctx ...interface{}) alertingLogging.Logger { + return &logWrapper{log.New(append([]interface{}{logger}, ctx...)...)} } type logWrapper struct { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 1713a118468..8b687c2d9ac 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -13,7 +13,6 @@ import ( "github.com/prometheus/client_golang/prometheus" alertingNotify "github.com/grafana/alerting/notify" - "github.com/grafana/alerting/receivers" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" @@ -49,14 +48,14 @@ type MultiOrgAlertmanager struct { orgStore store.OrgStore kvStore kvstore.KVStore - decryptFn receivers.GetDecryptedValueFn + decryptFn alertingNotify.GetDecryptedValueFn metrics *metrics.MultiOrgAlertmanager ns notifications.Service } func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore AlertingStore, orgStore store.OrgStore, - kvStore kvstore.KVStore, provStore provisioning.ProvisioningStore, decryptFn receivers.GetDecryptedValueFn, + kvStore kvstore.KVStore, provStore provisioning.ProvisioningStore, decryptFn alertingNotify.GetDecryptedValueFn, m *metrics.MultiOrgAlertmanager, ns notifications.Service, l log.Logger, s secrets.Service, ) (*MultiOrgAlertmanager, error) { moa := &MultiOrgAlertmanager{ diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go index f473a035a33..5cc12f15f86 100644 --- a/pkg/services/ngalert/notifier/receivers.go +++ b/pkg/services/ngalert/notifier/receivers.go @@ -35,9 +35,9 @@ type TestReceiverConfigResult struct { func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*TestReceiversResult, error) { receivers := make([]*alertingNotify.APIReceiver, 0, len(c.Receivers)) for _, r := range c.Receivers { - greceivers := make([]*alertingNotify.GrafanaReceiver, 0, len(r.GrafanaManagedReceivers)) + integrations := make([]*alertingNotify.GrafanaIntegrationConfig, 0, len(r.GrafanaManagedReceivers)) for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - greceivers = append(greceivers, &alertingNotify.GrafanaReceiver{ + integrations = append(integrations, &alertingNotify.GrafanaIntegrationConfig{ UID: gr.UID, Name: gr.Name, Type: gr.Type, @@ -48,8 +48,8 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei } receivers = append(receivers, &alertingNotify.APIReceiver{ ConfigReceiver: r.Receiver, - GrafanaReceivers: alertingNotify.GrafanaReceivers{ - Receivers: greceivers, + GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ + Integrations: integrations, }, }) } diff --git a/pkg/services/ngalert/notifier/receivers_test.go b/pkg/services/ngalert/notifier/receivers_test.go index 2c3b3102946..6ee01b9b9fe 100644 --- a/pkg/services/ngalert/notifier/receivers_test.go +++ b/pkg/services/ngalert/notifier/receivers_test.go @@ -11,19 +11,20 @@ import ( ) func TestInvalidReceiverError_Error(t *testing.T) { - e := alertingNotify.InvalidReceiverError{ - Receiver: &alertingNotify.GrafanaReceiver{ + e := alertingNotify.IntegrationValidationError{ + Integration: &alertingNotify.GrafanaIntegrationConfig{ Name: "test", + Type: "test-type", UID: "uid", }, Err: errors.New("this is an error"), } - require.Equal(t, "the receiver is invalid: this is an error", e.Error()) + require.Equal(t, `failed to validate integration "test" (UID uid) of type "test-type": this is an error`, e.Error()) } func TestReceiverTimeoutError_Error(t *testing.T) { - e := alertingNotify.ReceiverTimeoutError{ - Receiver: &alertingNotify.GrafanaReceiver{ + e := alertingNotify.IntegrationTimeoutError{ + Integration: &alertingNotify.GrafanaIntegrationConfig{ Name: "test", UID: "uid", }, @@ -44,18 +45,18 @@ func (e timeoutError) Timeout() bool { func TestProcessNotifierError(t *testing.T) { t.Run("assert ReceiverTimeoutError is returned for context deadline exceeded", func(t *testing.T) { - r := &alertingNotify.GrafanaReceiver{ + r := &alertingNotify.GrafanaIntegrationConfig{ Name: "test", UID: "uid", } - require.Equal(t, alertingNotify.ReceiverTimeoutError{ - Receiver: r, - Err: context.DeadlineExceeded, - }, alertingNotify.ProcessNotifierError(r, context.DeadlineExceeded)) + require.Equal(t, alertingNotify.IntegrationTimeoutError{ + Integration: r, + Err: context.DeadlineExceeded, + }, alertingNotify.ProcessIntegrationError(r, context.DeadlineExceeded)) }) t.Run("assert ReceiverTimeoutError is returned for *url.Error timeout", func(t *testing.T) { - r := &alertingNotify.GrafanaReceiver{ + r := &alertingNotify.GrafanaIntegrationConfig{ Name: "test", UID: "uid", } @@ -64,18 +65,18 @@ func TestProcessNotifierError(t *testing.T) { URL: "https://grafana.net", Err: timeoutError{}, } - require.Equal(t, alertingNotify.ReceiverTimeoutError{ - Receiver: r, - Err: urlError, - }, alertingNotify.ProcessNotifierError(r, urlError)) + require.Equal(t, alertingNotify.IntegrationTimeoutError{ + Integration: r, + Err: urlError, + }, alertingNotify.ProcessIntegrationError(r, urlError)) }) t.Run("assert unknown error is returned unmodified", func(t *testing.T) { - r := &alertingNotify.GrafanaReceiver{ + r := &alertingNotify.GrafanaIntegrationConfig{ Name: "test", UID: "uid", } err := errors.New("this is an error") - require.Equal(t, err, alertingNotify.ProcessNotifierError(r, err)) + require.Equal(t, err, alertingNotify.ProcessIntegrationError(r, err)) }) } diff --git a/pkg/services/ngalert/notifier/sender.go b/pkg/services/ngalert/notifier/sender.go index 13fcebba592..11c17c12d8a 100644 --- a/pkg/services/ngalert/notifier/sender.go +++ b/pkg/services/ngalert/notifier/sender.go @@ -50,7 +50,3 @@ func (s sender) SendEmail(ctx context.Context, cmd *receivers.SendEmailSettings) }, }) } - -func NewNotificationSender(ns notifications.Service) receivers.NotificationSender { - return &sender{ns: ns} -} diff --git a/pkg/services/ngalert/provisioning/compat.go b/pkg/services/ngalert/provisioning/compat.go new file mode 100644 index 00000000000..edd9a23bc15 --- /dev/null +++ b/pkg/services/ngalert/provisioning/compat.go @@ -0,0 +1,22 @@ +package provisioning + +import ( + alertingNotify "github.com/grafana/alerting/notify" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func EmbeddedContactPointToGrafanaIntegrationConfig(e definitions.EmbeddedContactPoint) (alertingNotify.GrafanaIntegrationConfig, error) { + data, err := e.Settings.MarshalJSON() + if err != nil { + return alertingNotify.GrafanaIntegrationConfig{}, err + } + return alertingNotify.GrafanaIntegrationConfig{ + UID: e.UID, + Name: e.Name, + Type: e.Type, + DisableResolveMessage: e.DisableResolveMessage, + Settings: data, + SecureSettings: nil, + }, nil +} diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go index 64d238f4093..13183c298f8 100644 --- a/pkg/services/ngalert/provisioning/contactpoints.go +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -7,9 +7,7 @@ import ( "fmt" "sort" - "github.com/grafana/alerting/logging" alertingNotify "github.com/grafana/alerting/notify" - "github.com/grafana/alerting/receivers" "github.com/prometheus/alertmanager/config" "github.com/grafana/grafana/pkg/components/simplejson" @@ -18,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -136,7 +133,7 @@ func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, or func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) (apimodels.EmbeddedContactPoint, error) { - if err := ValidateContactPoint(contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil { + if err := ValidateContactPoint(ctx, contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil { return apimodels.EmbeddedContactPoint{}, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } @@ -257,7 +254,7 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in } // validate merged values - if err := ValidateContactPoint(contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil { + if err := ValidateContactPoint(ctx, contactPoint, ecp.encryptionService.GetDecryptedValue); err != nil { return fmt.Errorf("%w: %s", ErrValidation, err.Error()) } @@ -500,28 +497,23 @@ func replaceReferences(oldName, newName string, routes ...*apimodels.Route) { } } -func ValidateContactPoint(e apimodels.EmbeddedContactPoint, decryptFunc receivers.GetDecryptedValueFn) error { +func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint, decryptFunc alertingNotify.GetDecryptedValueFn) error { if e.Type == "" { return fmt.Errorf("type should not be an empty string") } if e.Settings == nil { return fmt.Errorf("settings should not be empty") } - factory, exists := alertingNotify.Factory(e.Type) - if !exists { - return fmt.Errorf("unknown type '%s'", e.Type) - } - jsonBytes, err := e.Settings.MarshalJSON() + integration, err := EmbeddedContactPointToGrafanaIntegrationConfig(e) if err != nil { return err } - cfg, _ := receivers.NewFactoryConfig(&receivers.NotificationChannelConfig{ - Settings: jsonBytes, - Type: e.Type, - }, nil, decryptFunc, nil, nil, func(ctx ...interface{}) logging.Logger { - return &logging.FakeLogger{} - }, setting.BuildVersion) - if _, err := factory(cfg); err != nil { + _, err = alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{ + GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ + Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration}, + }, + }, decryptFunc) + if err != nil { return err } return nil diff --git a/pkg/services/provisioning/alerting/contact_point_types.go b/pkg/services/provisioning/alerting/contact_point_types.go index 8fe5d2ac3db..391c9ff1fc2 100644 --- a/pkg/services/provisioning/alerting/contact_point_types.go +++ b/pkg/services/provisioning/alerting/contact_point_types.go @@ -95,7 +95,7 @@ func (config *ReceiverV1) mapToModel(name string) (definitions.EmbeddedContactPo } // As the values are not encrypted when coming from disk files, // we can simply return the fallback for validation. - err := provisioning.ValidateContactPoint(cp, func(_ context.Context, _ map[string][]byte, _, fallback string) string { + err := provisioning.ValidateContactPoint(context.Background(), cp, func(_ context.Context, _ map[string][]byte, _, fallback string) string { return fallback }) if err != nil { diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index 535c6395006..a7a4a83a759 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -480,7 +480,7 @@ func (m *migration) validateAlertmanagerConfig(config *PostableUserConfig) error return err } var ( - cfg = &alertingNotify.GrafanaReceiver{ + cfg = &alertingNotify.GrafanaIntegrationConfig{ UID: gr.UID, Name: gr.Name, Type: gr.Type, @@ -504,7 +504,7 @@ func (m *migration) validateAlertmanagerConfig(config *PostableUserConfig) error return fallback } _, err = alertingNotify.BuildReceiverConfiguration(context.Background(), &alertingNotify.APIReceiver{ - GrafanaReceivers: alertingNotify.GrafanaReceivers{Receivers: []*alertingNotify.GrafanaReceiver{cfg}}, + GrafanaIntegrations: alertingNotify.GrafanaIntegrations{Integrations: []*alertingNotify.GrafanaIntegrationConfig{cfg}}, }, decryptFunc) if err != nil { return err diff --git a/pkg/services/sqlstore/migrations/ualert/ualert_test.go b/pkg/services/sqlstore/migrations/ualert/ualert_test.go index ef2086ccb54..98a56c07c6f 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert_test.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert_test.go @@ -72,7 +72,7 @@ func Test_validateAlertmanagerConfig(t *testing.T) { SecureSettings: map[string]string{"url": invalidUri}, }, }, - err: fmt.Errorf("failed to validate receiver \"SlackWithBadURL\" of type \"slack\": failed to parse notifier SlackWithBadURL (UID: test-uid): invalid URL %q", invalidUri), + err: fmt.Errorf("failed to validate integration \"SlackWithBadURL\" (UID test-uid) of type \"slack\": invalid URL %q", invalidUri), }, { name: "when a slack receiver has an invalid recipient - it should not error", diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index e6eb902872c..9ab9a901f6f 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -102,7 +102,7 @@ func TestIntegrationAlertmanagerConfigurationIsTransactional(t *testing.T) { require.NoError(t, err) var res map[string]interface{} require.NoError(t, json.Unmarshal(b, &res)) - require.Equal(t, `failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver "slack.receiver" of type "slack": token must be specified when using the Slack chat API`, res["message"]) + require.Regexp(t, `^failed to save and apply Alertmanager configuration: failed to build integration map: failed to validate integration "slack.receiver" \(UID [^\)]+\) of type "slack": token must be specified when using the Slack chat API`, res["message"]) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index c6bd1f5bb4e..2990e0d21ee 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -205,32 +205,34 @@ func TestIntegrationTestReceivers(t *testing.T) { require.NoError(t, json.Unmarshal(b, &result)) require.Len(t, result.Receivers, 1) require.Len(t, result.Receivers[0].Configs, 1) + require.Regexp(t, `failed to validate integration "receiver-1" \(UID[^\)]+\) of type "email": could not find addresses in settings`, result.Receivers[0].Configs[0].Error) expectedJSON := fmt.Sprintf(`{ - "alert": { - "annotations": { - "summary": "Notification test", - "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" - }, - "labels": { - "alertname": "TestAlert", - "instance": "Grafana" - } - }, - "receivers": [{ - "name":"receiver-1", - "grafana_managed_receiver_configs": [ - { - "name": "receiver-1", - "uid": "%s", - "status": "failed", - "error": "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings" + "alert": { + "annotations": { + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" + }, + "labels": { + "alertname": "TestAlert", + "instance": "Grafana" } - ] - }], - "notified_at": "%s" - }`, + }, + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "name": "receiver-1", + "uid": "%s", + "status": "failed", + "error": %q + } + ] + }], + "notified_at": "%s" + }`, result.Receivers[0].Configs[0].UID, + result.Receivers[0].Configs[0].Error, result.NotifiedAt.Format(time.RFC3339Nano)) require.JSONEq(t, expectedJSON, string(b)) }) @@ -392,6 +394,7 @@ func TestIntegrationTestReceivers(t *testing.T) { require.Len(t, result.Receivers, 2) require.Len(t, result.Receivers[0].Configs, 1) require.Len(t, result.Receivers[1].Configs, 1) + require.Regexp(t, `failed to validate integration "receiver-1" \(UID[^\)]+\) of type "email": could not find addresses in settings`, result.Receivers[0].Configs[0].Error) expectedJSON := fmt.Sprintf(`{ "alert": { @@ -411,7 +414,7 @@ func TestIntegrationTestReceivers(t *testing.T) { "name": "receiver-1", "uid": "%s", "status": "failed", - "error": "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings" + "error": %q } ] }, { @@ -428,6 +431,7 @@ func TestIntegrationTestReceivers(t *testing.T) { "notified_at": "%s" }`, result.Receivers[0].Configs[0].UID, + result.Receivers[0].Configs[0].Error, result.Receivers[1].Configs[0].UID, result.NotifiedAt.Format(time.RFC3339Nano)) require.JSONEq(t, expectedJSON, string(b)) @@ -1056,6 +1060,7 @@ func (nc *mockNotificationChannel) ServeHTTP(res http.ResponseWriter, req *http. body := getBody(nc.t, req.Body) nc.receivedNotifications[key] = append(nc.receivedNotifications[key], body) + res.Header().Set("Content-Type", "application/json") res.WriteHeader(http.StatusOK) fmt.Fprint(res, nc.responses[paths[0]]) } diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index ce6eaf000ab..ee759178674 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -10,11 +10,12 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/expr" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/expr" + 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" @@ -75,7 +76,8 @@ func postRequest(t *testing.T, url string, body string, expStatusCode int) *http if expStatusCode != resp.StatusCode { b, err := io.ReadAll(resp.Body) require.NoError(t, err) - t.Fatal(string(b)) + t.Log(string(b)) + require.Equal(t, expStatusCode, resp.StatusCode) } return resp } From 73920b1e34c14f00072632bdc37f4b76d7f1603b Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 25 Apr 2023 19:44:32 +0200 Subject: [PATCH 404/729] Plugins: Refactor cleaning of call resource response headers (#67145) First part of #66889 moving cleaning of call resource response headers within plugin management client. --- pkg/api/plugin_resource.go | 17 --- pkg/api/plugins_test.go | 27 +--- pkg/plugins/manager/client/client.go | 49 +++++++- pkg/plugins/manager/client/client_test.go | 117 +++++++++++++++++- .../clientmiddleware/caching_middleware.go | 8 +- .../resource_response_middleware.go | 65 ++++++++++ .../resource_response_middleware_test.go | 41 ++++++ .../clientmiddleware/testing.go | 6 - .../clientmiddleware/utils.go | 11 ++ .../pluginsintegration/pluginsintegration.go | 1 + 10 files changed, 283 insertions(+), 59 deletions(-) create mode 100644 pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware.go create mode 100644 pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware_test.go create mode 100644 pkg/services/pluginsintegration/clientmiddleware/utils.go diff --git a/pkg/api/plugin_resource.go b/pkg/api/plugin_resource.go index 2b69379b920..89bb1884da3 100644 --- a/pkg/api/plugin_resource.go +++ b/pkg/api/plugin_resource.go @@ -161,7 +161,6 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt // Expected that headers and status are only part of first stream if processedStreams == 0 { - var hasContentType bool for k, values := range resp.Headers { // Convert the keys to the canonical format of MIME headers. // This ensures that we can safely add/overwrite headers @@ -169,15 +168,6 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt // and be sure they won't be present multiple times in the response. k = textproto.CanonicalMIMEHeaderKey(k) - switch k { - case "Set-Cookie": - // Due to security reasons we don't want to forward - // cookies from a backend plugin to clients/browsers. - continue - case "Content-Type": - hasContentType = true - } - for _, v := range values { // TODO: Figure out if we should use Set here instead // nolint:gocritic @@ -185,13 +175,6 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt } } - // Make sure a content type always is returned in response - if !hasContentType && resp.Status != http.StatusNoContent { - w.Header().Set("Content-Type", "application/json") - } - - proxyutil.SetProxyResponseHeaders(w.Header()) - w.WriteHeader(resp.Status) } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 9de3a41e4cc..9dce4ff7ee9 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -388,30 +388,9 @@ 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")) -} - -func TestMakePluginResourceRequestSetCookieNotPresent(t *testing.T) { - hs := HTTPServer{ - Cfg: setting.NewCfg(), - log: log.New(), - pluginClient: &fakePluginClient{ - headers: map[string][]string{"Set-Cookie": {"monster"}}, - }, - } - 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.Empty(t, resp.Header().Values("Set-Cookie"), "Set-Cookie header should not be present") + res := resp.Result() + require.NoError(t, res.Body.Close()) + require.Equal(t, http.StatusOK, res.StatusCode) } func TestMakePluginResourceRequestContentTypeUnique(t *testing.T) { diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index a1bd1e864f6..1a3cf03d373 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "net/textproto" "strings" @@ -16,6 +17,12 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" ) +const ( + setCookieHeaderName = "Set-Cookie" + contentTypeHeaderName = "Content-Type" + defaultContentType = "application/json" +) + var _ plugins.Client = (*Service)(nil) type Service struct { @@ -99,13 +106,22 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq }, totalBytes, func() error { removeConnectionHeaders(req.Headers) removeHopByHopHeaders(req.Headers) + removeNonAllowedHeaders(req.Headers) + processedStreams := 0 wrappedSender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { - if res != nil && len(res.Headers) > 0 { - removeConnectionHeaders(res.Headers) - removeHopByHopHeaders(res.Headers) + // Expected that headers and status are only part of first stream + if processedStreams == 0 && res != nil { + if len(res.Headers) > 0 { + removeConnectionHeaders(res.Headers) + removeHopByHopHeaders(res.Headers) + removeNonAllowedHeaders(res.Headers) + } + + ensureContentTypeHeader(res) } + processedStreams++ return sender.Send(res) }) @@ -293,6 +309,33 @@ func removeHopByHopHeaders(h map[string][]string) { } } +func removeNonAllowedHeaders(h map[string][]string) { + for k := range h { + if textproto.CanonicalMIMEHeaderKey(k) == setCookieHeaderName { + delete(h, k) + } + } +} + +// ensureContentTypeHeader makes sure a content type always is returned in response. +func ensureContentTypeHeader(res *backend.CallResourceResponse) { + if res == nil { + return + } + + var hasContentType bool + for k := range res.Headers { + if textproto.CanonicalMIMEHeaderKey(k) == contentTypeHeaderName { + hasContentType = true + break + } + } + + if !hasContentType && res.Status != http.StatusNoContent { + res.Headers[contentTypeHeaderName] = []string{defaultContentType} + } +} + type callResourceResponseSenderFunc func(res *backend.CallResourceResponse) error func (fn callResourceResponseSenderFunc) Send(res *backend.CallResourceResponse) error { diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 45ae51d35d5..fe308710c1e 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -136,7 +136,6 @@ func TestCallResource(t *testing.T) { res := responses[0] require.Equal(t, http.StatusOK, res.Status) require.Equal(t, []byte(backendResponse), res.Body) - require.Len(t, res.Headers, 1) require.Equal(t, "should not be deleted", actualReq.Headers["X-Custom"][0]) }) @@ -200,9 +199,123 @@ func TestCallResource(t *testing.T) { res := responses[0] require.Equal(t, http.StatusOK, res.Status) require.Equal(t, []byte(backendResponse), res.Body) - require.Len(t, res.Headers, 1) require.Equal(t, "should not be deleted", actualReq.Headers["X-Custom"][0]) }) + + t.Run("Should remove non-allowed response headers", func(t *testing.T) { + resHeaders := map[string][]string{ + setCookieHeaderName: {"monster"}, + "X-Custom": {"should not be deleted"}, + } + + req := &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + PluginID: "pid", + }, + } + + responses := []*backend.CallResourceResponse{} + sender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { + responses = append(responses, res) + return nil + }) + + p.RegisterClient(&fakePluginBackend{ + crr: func(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return sender.Send(&backend.CallResourceResponse{ + Headers: resHeaders, + Status: http.StatusOK, + Body: []byte(backendResponse), + }) + }, + }) + err := registry.Add(context.Background(), p) + require.NoError(t, err) + + client := ProvideService(registry, &config.Cfg{}) + + err = client.CallResource(context.Background(), req, sender) + require.NoError(t, err) + + require.Len(t, responses, 1) + res := responses[0] + require.Equal(t, http.StatusOK, res.Status) + require.Equal(t, []byte(backendResponse), res.Body) + require.Empty(t, res.Headers[setCookieHeaderName]) + require.Equal(t, "should not be deleted", res.Headers["X-Custom"][0]) + }) + + t.Run("Should ensure content type header", func(t *testing.T) { + tcs := []struct { + contentType string + responseStatus int + expContentType string + }{ + { + contentType: "", + responseStatus: http.StatusOK, + expContentType: defaultContentType, + }, + { + contentType: "text/plain", + responseStatus: http.StatusOK, + expContentType: "text/plain", + }, + { + contentType: "", + responseStatus: http.StatusNoContent, + expContentType: "", + }, + } + + for _, tc := range tcs { + t.Run(fmt.Sprintf("content type=%s, status=%d, exp=%s", tc.contentType, tc.responseStatus, tc.expContentType), func(t *testing.T) { + resHeaders := map[string][]string{} + + if tc.contentType != "" { + resHeaders[contentTypeHeaderName] = []string{tc.contentType} + } + + req := &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + PluginID: "pid", + }, + } + + responses := []*backend.CallResourceResponse{} + sender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { + responses = append(responses, res) + return nil + }) + + p.RegisterClient(&fakePluginBackend{ + crr: func(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return sender.Send(&backend.CallResourceResponse{ + Headers: resHeaders, + Status: tc.responseStatus, + Body: []byte(backendResponse), + }) + }, + }) + err := registry.Add(context.Background(), p) + require.NoError(t, err) + + client := ProvideService(registry, &config.Cfg{}) + + err = client.CallResource(context.Background(), req, sender) + require.NoError(t, err) + + require.Len(t, responses, 1) + res := responses[0] + + if tc.expContentType != "" { + require.Equal(t, tc.expContentType, res.Headers[contentTypeHeaderName][0]) + } else { + require.Empty(t, res.Headers[contentTypeHeaderName]) + } + }) + } + }) } type fakePluginBackend struct { diff --git a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go index 194b92832db..c902604d4c1 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/caching_middleware.go @@ -123,7 +123,7 @@ func (m *CachingMiddleware) CallResource(ctx context.Context, req *backend.CallR return m.next.CallResource(ctx, req, sender) } // Otherwise, intercept the responses in a wrapped sender so we can cache them first - cacheSender := cachedSenderFunc(func(res *backend.CallResourceResponse) error { + cacheSender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { cr.UpdateCacheFn(ctx, res) return sender.Send(res) }) @@ -150,9 +150,3 @@ func (m *CachingMiddleware) PublishStream(ctx context.Context, req *backend.Publ func (m *CachingMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { return m.next.RunStream(ctx, req, sender) } - -type cachedSenderFunc func(res *backend.CallResourceResponse) error - -func (fn cachedSenderFunc) Send(res *backend.CallResourceResponse) error { - return fn(res) -} diff --git a/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware.go new file mode 100644 index 00000000000..31d3095a4a7 --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware.go @@ -0,0 +1,65 @@ +package clientmiddleware + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/util/proxyutil" +) + +// NewResourceResponseMiddleware creates a new plugins.ClientMiddleware +// that will enforce HTTP header rules for backend.CallResourceResponse's. +func NewResourceResponseMiddleware() plugins.ClientMiddleware { + return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { + return &ResourceResponseMiddleware{ + next: next, + } + }) +} + +type ResourceResponseMiddleware struct { + next plugins.Client +} + +func (m *ResourceResponseMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return m.next.QueryData(ctx, req) +} + +func (m *ResourceResponseMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + if req == nil || sender == nil { + return m.next.CallResource(ctx, req, sender) + } + + processedStreams := 0 + wrappedSender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { + if processedStreams == 0 { + proxyutil.SetProxyResponseHeaders(res.Headers) + } + + processedStreams++ + return sender.Send(res) + }) + + return m.next.CallResource(ctx, req, wrappedSender) +} + +func (m *ResourceResponseMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return m.next.CheckHealth(ctx, req) +} + +func (m *ResourceResponseMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) { + return m.next.CollectMetrics(ctx, req) +} + +func (m *ResourceResponseMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return m.next.SubscribeStream(ctx, req) +} + +func (m *ResourceResponseMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return m.next.PublishStream(ctx, req) +} + +func (m *ResourceResponseMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + return m.next.RunStream(ctx, req, sender) +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware_test.go new file mode 100644 index 00000000000..2e0fcf60f4b --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/resource_response_middleware_test.go @@ -0,0 +1,41 @@ +package clientmiddleware + +import ( + "context" + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/plugins/manager/client/clienttest" + "github.com/stretchr/testify/require" +) + +func TestResourceResponseMiddleware(t *testing.T) { + t.Run("Should set proxy response headers when calling CallResource", func(t *testing.T) { + crResp := &backend.CallResourceResponse{ + Status: http.StatusOK, + Headers: map[string][]string{ + "X-Custom": {"Should not be deleted"}, + }, + } + cdt := clienttest.NewClientDecoratorTest(t, + clienttest.WithMiddlewares(NewResourceResponseMiddleware()), + clienttest.WithResourceResponses([]*backend.CallResourceResponse{crResp}), + ) + + var sentResponse *backend.CallResourceResponse + sender := callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { + sentResponse = res + return nil + }) + + err := cdt.Decorator.CallResource(context.Background(), &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{}, + }, sender) + require.NoError(t, err) + + require.NotNil(t, sentResponse) + require.Equal(t, "sandbox", sentResponse.Headers["Content-Security-Policy"][0]) + require.Equal(t, "Should not be deleted", sentResponse.Headers["X-Custom"][0]) + }) +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/testing.go b/pkg/services/pluginsintegration/clientmiddleware/testing.go index d26c462f388..6f5f06806e0 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/testing.go +++ b/pkg/services/pluginsintegration/clientmiddleware/testing.go @@ -2,12 +2,6 @@ package clientmiddleware import "github.com/grafana/grafana-plugin-sdk-go/backend" -type callResourceResponseSenderFunc func(res *backend.CallResourceResponse) error - -func (fn callResourceResponseSenderFunc) Send(res *backend.CallResourceResponse) error { - return fn(res) -} - var nopCallResourceSender = callResourceResponseSenderFunc(func(res *backend.CallResourceResponse) error { return nil }) diff --git a/pkg/services/pluginsintegration/clientmiddleware/utils.go b/pkg/services/pluginsintegration/clientmiddleware/utils.go new file mode 100644 index 00000000000..dc1f768532f --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/utils.go @@ -0,0 +1,11 @@ +package clientmiddleware + +import ( + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +type callResourceResponseSenderFunc func(res *backend.CallResourceResponse) error + +func (fn callResourceResponseSenderFunc) Send(res *backend.CallResourceResponse) error { + return fn(res) +} diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 12770e78a81..c057567102f 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -114,6 +114,7 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken clientmiddleware.NewClearAuthHeadersMiddleware(), clientmiddleware.NewOAuthTokenMiddleware(oAuthTokenService), clientmiddleware.NewCookiesMiddleware(skipCookiesNames), + clientmiddleware.NewResourceResponseMiddleware(), } // Placing the new service implementation behind a feature flag until it is known to be stable From 96a191e6436331f2c16181279813799e5d5945a6 Mon Sep 17 00:00:00 2001 From: Kate Brenner <32871890+katebrenner@users.noreply.github.com> Date: Tue, 25 Apr 2023 12:19:19 -0600 Subject: [PATCH 405/729] Add link to building backend datasource (#63535) * add link to building backend datasource * Update docs/sources/tutorials/build-a-data-source-plugin/index.md Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> * Add punctuation. --------- Co-authored-by: Eve Meelan <81647476+Eve832@users.noreply.github.com> Co-authored-by: Ursula Kallio --- docs/sources/tutorials/build-a-data-source-plugin/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sources/tutorials/build-a-data-source-plugin/index.md b/docs/sources/tutorials/build-a-data-source-plugin/index.md index 271f019f4b0..700e621d4fa 100644 --- a/docs/sources/tutorials/build-a-data-source-plugin/index.md +++ b/docs/sources/tutorials/build-a-data-source-plugin/index.md @@ -38,6 +38,8 @@ In this tutorial, you'll: {{< docs/shared lookup="tutorials/create-plugin.md" source="grafana" version="latest" >}} +To learn how to create a backend data source plugin, see [Build a data source backend plugin](/docs/grafana/latest/tutorials/build-a-data-source-backend-plugin). + ## Anatomy of a plugin {{< docs/shared lookup="tutorials/plugin-anatomy.md" source="grafana" version="latest" >}} From 17b8d28cae8d2ef431756d7679836d0d5ab47351 Mon Sep 17 00:00:00 2001 From: Kate Brenner <32871890+katebrenner@users.noreply.github.com> Date: Tue, 25 Apr 2023 12:20:14 -0600 Subject: [PATCH 406/729] Update build a datasource plugin tutorial (#66381) * update tutorial * add punctuation --- .../sources/shared/tutorials/create-plugin.md | 4 +-- .../build-a-data-source-plugin/index.md | 32 ++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/sources/shared/tutorials/create-plugin.md b/docs/sources/shared/tutorials/create-plugin.md index 671154e09ba..4feda888c0a 100755 --- a/docs/sources/shared/tutorials/create-plugin.md +++ b/docs/sources/shared/tutorials/create-plugin.md @@ -31,10 +31,10 @@ Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugi ``` 1. Restart the Grafana server for Grafana to discover your plugin. -1. Open Grafana and go to **Configuration** -> **Plugins**. Make sure that your plugin is there. +1. Open Grafana and go to **Connections** -> **Connect Data**. Make sure that your data source is there. By default, Grafana logs whenever it discovers a plugin: ``` -INFO[01-01|12:00:00] Registering plugin logger=plugins name=my-plugin +INFO[01-01|12:00:00] Plugin registered logger=plugin.loader pluginID=my-plugin ``` diff --git a/docs/sources/tutorials/build-a-data-source-plugin/index.md b/docs/sources/tutorials/build-a-data-source-plugin/index.md index 700e621d4fa..f6a68a867ab 100644 --- a/docs/sources/tutorials/build-a-data-source-plugin/index.md +++ b/docs/sources/tutorials/build-a-data-source-plugin/index.md @@ -70,7 +70,7 @@ async testDatasource() ## Data frames -Nowadays there are countless of different databases, each with their own ways of querying data. To be able to support all the different data formats, Grafana consolidates the data into a unified data structure called _data frames_. +Nowadays there are countless different databases, each with their own ways of querying data. To be able to support all the different data formats, Grafana consolidates the data into a unified data structure called _data frames_. Let's see how to create and return a data frame from the `query` method. In this step, you'll change the code in the starter plugin to return a [sine wave](https://en.wikipedia.org/wiki/Sine_wave). @@ -98,6 +98,14 @@ Let's see how to create and return a data frame from the `query` method. In this const query = defaults(target, defaultQuery); ``` +1. Create a default query at the top of datasource.ts: + + ```ts + export const defaultQuery: Partial = { + constant: 6.5, + }; + ``` + 1. Create a data frame with a time field and a number field: ```ts @@ -190,19 +198,19 @@ Now that you've defined the query model you wish to support, the next step is to **QueryEditor.tsx** ```ts - const query = defaults(this.props.query, defaultQuery); const { queryText, constant, frequency } = query; ``` ```ts - + + + ``` 1. Add a event listener for the new property. ```ts - onFrequencyChange = (event: ChangeEvent) => { - const { onChange, query, onRunQuery } = this.props; + const onFrequencyChange = (event: ChangeEvent) => { onChange({ ...query, frequency: parseFloat(event.target.value) }); // executes the query onRunQuery(); @@ -255,21 +263,15 @@ Just like query editor, the form field in the config editor calls the registered **ConfigEditor.tsx** ```ts -
- -
+ + + ``` 1. Add a event listener for the new option. ```ts - onResolutionChange = (event: ChangeEvent) => { - const { onOptionsChange, options } = this.props; + const onResolutionChange = (event: ChangeEvent) => { const jsonData = { ...options.jsonData, resolution: parseFloat(event.target.value), From ad964a0e1da32261852f6b59baa74ed2785c8a78 Mon Sep 17 00:00:00 2001 From: Stefan Dunkler Date: Tue, 25 Apr 2023 20:32:40 +0200 Subject: [PATCH 407/729] Update Screenshot (#67233) --- .../panels-visualizations/visualizations/traces/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/panels-visualizations/visualizations/traces/index.md b/docs/sources/panels-visualizations/visualizations/traces/index.md index d41ea94a2b5..20537e35f69 100644 --- a/docs/sources/panels-visualizations/visualizations/traces/index.md +++ b/docs/sources/panels-visualizations/visualizations/traces/index.md @@ -23,4 +23,4 @@ For more information about traces and how to use them, refer to the following do - [Tracing in Explore]({{< relref "../../../explore/trace-integration/" >}}) - [Getting started with Tempo](/docs/tempo/latest/getting-started) -{{< figure src="/static/img/docs/explore/explore-trace-view-full-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the trace view" >}} +{{< figure src="/static/img/docs/explore/trace-view-9-4.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the trace view" >}} From 581cc85ba53f3aa4bae8ee89af96fea0b0608439 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Tue, 25 Apr 2023 21:07:16 +0200 Subject: [PATCH 408/729] Add analytics to new DS picker and onboarding experience (#67060) * Add analytics to ds picker advanced mode * Add analytics to ds picker dropdown --- .../components/picker/DataSourceDropdown.tsx | 28 ++++++--- .../components/picker/DataSourceModal.tsx | 63 +++++++++++++++++-- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 01436e506e8..29c9c63b814 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { DataSourceJsonData } from '@grafana/schema'; import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; @@ -18,6 +19,14 @@ import { DataSourceModal } from './DataSourceModal'; import { PickerContentProps, DataSourceDropdownProps } from './types'; import { dataSourceLabel } from './utils'; +const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked'; +const INTERACTION_ITEM = { + OPEN_DROPDOWN: 'open_dspicker', + SELECT_DS: 'select_ds', + ADD_FILE: 'add_file', + OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker', +}; + export function DataSourceDropdown(props: DataSourceDropdownProps) { const { current, onChange, ...restProps } = props; @@ -25,6 +34,10 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { const [markerElement, setMarkerElement] = useState(); const [selectorElement, setSelectorElement] = useState(); const [filterTerm, setFilterTerm] = useState(); + const openDropdown = () => { + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); + setOpen(true); + }; const currentDataSourceInstanceSettings = useDatasource(current); @@ -93,20 +106,13 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { ) : ( -
{ - setOpen(true); - }} - > +
} suffix={} value={dataSourceLabel(currentDataSourceInstanceSettings)} - onFocus={() => { - setOpen(true); - }} + onFocus={openDropdown} />
)} @@ -135,6 +141,7 @@ const PickerContent = React.forwardRef((prop const changeCallback = useCallback( (ds: DataSourceInstanceSettings) => { onChange(ds); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type }); }, [onChange] ); @@ -142,6 +149,7 @@ const PickerContent = React.forwardRef((prop const clickAddCSVCallback = useCallback(() => { onClickAddCSV?.(); onClose(); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE }); }, [onClickAddCSV, onClose]); const styles = useStyles2(getStylesPickerContent); @@ -176,6 +184,7 @@ const PickerContent = React.forwardRef((prop showModal(DataSourceModal, { enableFileUpload: props.enableFileUpload, fileUploadOptions: props.fileUploadOptions, + reportedInteractionFrom: 'ds_picker', current, onDismiss: hideModal, onChange: (ds) => { @@ -183,6 +192,7 @@ const PickerContent = React.forwardRef((prop hideModal(); }, }); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER }); }} > Open advanced data source picker diff --git a/public/app/features/datasources/components/picker/DataSourceModal.tsx b/public/app/features/datasources/components/picker/DataSourceModal.tsx index a51c04b04e6..4c18c10b66a 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.tsx @@ -1,8 +1,10 @@ import { css } from '@emotion/css'; +import { once } from 'lodash'; import React, { useState } from 'react'; import { DropzoneOptions } from 'react-dropzone'; import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Modal, FileDropzone, @@ -20,6 +22,15 @@ import { DATASOURCES_ROUTES } from 'app/features/datasources/constants'; import { DataSourceList } from './DataSourceList'; +const INTERACTION_EVENT_NAME = 'dashboards_dspickermodal_clicked'; +const INTERACTION_ITEM = { + SELECT_DS: 'select_ds', + UPLOAD_FILE: 'upload_file', + CONFIG_NEW_DS: 'config_new_ds', + SEARCH: 'search', + DISMISS: 'dismiss', +}; + interface DataSourceModalProps { onChange: (ds: DataSourceInstanceSettings) => void; current: DataSourceRef | string | null | undefined; @@ -27,6 +38,7 @@ interface DataSourceModalProps { recentlyUsed?: string[]; enableFileUpload?: boolean; fileUploadOptions?: DropzoneOptions; + reportedInteractionFrom?: string; } export function DataSourceModal({ @@ -35,13 +47,36 @@ export function DataSourceModal({ onChange, current, onDismiss, + reportedInteractionFrom, }: DataSourceModalProps) { const styles = useStyles2(getDataSourceModalStyles); const [search, setSearch] = useState(''); + const analyticsInteractionSrc = reportedInteractionFrom || 'modal'; const newDataSourceURL = config.featureToggles.dataConnectionsConsole ? CONNECTIONS_ROUTES.DataSourcesNew : DATASOURCES_ROUTES.New; + const onDismissModal = () => { + onDismiss(); + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.DISMISS, src: analyticsInteractionSrc }); + }; + const onChangeDataSource = (ds: DataSourceInstanceSettings) => { + onChange(ds); + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.SELECT_DS, + ds_type: ds.type, + src: analyticsInteractionSrc, + }); + }; + // Memoizing to keep once() cached so it avoids reporting multiple times + const reportSearchUsageOnce = React.useMemo( + () => + once(() => { + reportInteraction(INTERACTION_EVENT_NAME, { item: 'search', src: analyticsInteractionSrc }); + }), + [analyticsInteractionSrc] + ); + return (
} placeholder="Search data source" - onChange={(e) => setSearch(e.currentTarget.value)} + onChange={(e) => { + setSearch(e.currentTarget.value); + reportSearchUsageOnce(); + }} /> ds.name.includes(search) && !ds.meta.builtIn} - onChange={onChange} + onChange={onChangeDataSource} current={current} /> @@ -79,7 +117,7 @@ export function DataSourceModal({ filter={(ds) => !!ds.meta.builtIn} dashboard mixed - onChange={onChange} + onChange={onChangeDataSource} current={current} /> {enableFileUpload && ( @@ -94,6 +132,10 @@ export function DataSourceModal({ onDrop: (...args) => { fileUploadOptions?.onDrop?.(...args); onDismiss(); + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.UPLOAD_FILE, + src: analyticsInteractionSrc, + }); }, }} > @@ -102,7 +144,16 @@ export function DataSourceModal({ )}
- + { + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.CONFIG_NEW_DS, + src: analyticsInteractionSrc, + }); + }} + > Configure a new data source
From ff617722182aa75421bb4df3d69d069844839d82 Mon Sep 17 00:00:00 2001 From: oneoneonepig Date: Wed, 26 Apr 2023 03:13:26 +0800 Subject: [PATCH 409/729] Doc: Fix typo (#66929) Update index.md --- .../setup-grafana/image-rendering/troubleshooting/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md index 20d075dfcaf..0abf7cf64a3 100644 --- a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md +++ b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md @@ -114,7 +114,7 @@ If this happens, then you have to add the certificate to the trust store. If you ``` [root@server ~]# [ -d /usr/share/grafana/.pki/nssdb ] || mkdir -p /usr/share/grafana/.pki/nssdb -[root@merver ~]# certutil -d sql:/usr/share/grafana/.pki/nssdb -A -n internal-root-ca -t C -i /etc/pki/tls/certs/internal-root-ca.crt.pem +[root@server ~]# certutil -d sql:/usr/share/grafana/.pki/nssdb -A -n internal-root-ca -t C -i /etc/pki/tls/certs/internal-root-ca.crt.pem [root@server ~]# chown -R grafana: /usr/share/grafana/.pki/nssdb ``` From 6e4fe51fe8f0da7719eb933ef77c6e8b46dae126 Mon Sep 17 00:00:00 2001 From: Sarah Zinger Date: Tue, 25 Apr 2023 16:07:58 -0400 Subject: [PATCH 410/729] Cloudwatch Logs: Update Cheatsheet (#67161) Cloudwatch: Update Cheatsheet --- .../cloudwatch/components/LogsCheatSheet.tsx | 327 +++++++++++++----- 1 file changed, 243 insertions(+), 84 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx index f0d816e6941..880639b00b3 100644 --- a/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; import { stripIndent, stripIndents } from 'common-tags'; import Prism from 'prismjs'; -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; -import { QueryEditorHelpProps } from '@grafana/data'; +import { Collapse } from '@grafana/ui'; import { flattenTokens } from '@grafana/ui/src/slate-plugins/slate-prism'; import tokenizer from '../language/cloudwatch-logs/syntax'; @@ -12,12 +12,13 @@ import { CloudWatchQuery } from '../types'; interface QueryExample { category: string; examples: Array<{ - title: string; + title?: string; + description?: string; expr: string; }>; } -const CLIQ_EXAMPLES: QueryExample[] = [ +const QUERIES: QueryExample[] = [ { category: 'Lambda', examples: [ @@ -28,19 +29,19 @@ const CLIQ_EXAMPLES: QueryExample[] = [ }, { title: 'Determine the amount of overprovisioned memory', - expr: stripIndent` - filter @type = "REPORT" | - stats max(@memorySize / 1024 / 1024) as provisonedMemoryMB, - min(@maxMemoryUsed / 1024 / 1024) as smallestMemoryRequestMB, - avg(@maxMemoryUsed / 1024 / 1024) as avgMemoryUsedMB, - max(@maxMemoryUsed / 1024 / 1024) as maxMemoryUsedMB, - provisonedMemoryMB - maxMemoryUsedMB as overProvisionedMB`, + expr: stripIndent`filter @type = "REPORT" + | stats max(@memorySize / 1000 / 1000) as provisionedMemoryMB, + min(@maxMemoryUsed / 1000 / 1000) as smallestMemoryRequestMB, + avg(@maxMemoryUsed / 1000 / 1000) as avgMemoryUsedMB, + max(@maxMemoryUsed / 1000 / 1000) as maxMemoryUsedMB, + provisionedMemoryMB - maxMemoryUsedMB as overProvisionedMB + `, }, { title: 'Find the most expensive requests', - expr: stripIndents`filter @type = "REPORT" | - fields @requestId, @billedDuration | - sort by @billedDuration desc`, + expr: stripIndents`filter @type = "REPORT" + | fields @requestId, @billedDuration + | sort by @billedDuration desc`, }, ], }, @@ -69,6 +70,18 @@ const CLIQ_EXAMPLES: QueryExample[] = [ sort numRejections desc | limit 20`, }, + { + title: 'Find the top 15 packet transfers across hosts', + expr: stripIndents`stats sum(packets) as packetsTransferred by srcAddr, dstAddr + | sort packetsTransferred desc + | limit 15`, + }, + { + title: 'Find the IP addresses where flow records were skipped during the capture window', + expr: stripIndents`filter logStatus="SKIPDATA" + | stats count(*) by bin(1h) as t + | sort t`, + }, ], }, { @@ -91,6 +104,26 @@ const CLIQ_EXAMPLES: QueryExample[] = [ expr: stripIndents`filter eventName="CreateUser" | fields awsRegion, requestParameters.userName, responseElements.user.arn`, }, + { + title: 'Find EC2 hosts that were started or stopped in a given AWS Region', + expr: stripIndents`filter (eventName="StartInstances" or eventName="StopInstances") and region="us-east-2"`, + }, + { + title: 'Find the number of records where an exception occurred while invoking the UpdateTrail API', + expr: stripIndents`filter eventName="UpdateTrail" and ispresent(errorCode) | stats count(*) by errorCode, errorMessage`, + }, + { + title: 'Find log entries where TLS 1.0 or 1.1 was used', + expr: stripIndents`filter tlsDetails.tlsVersion in [ "TLSv1", "TLSv1.1" ] + | stats count(*) as numOutdatedTlsCalls by userIdentity.accountId, recipientAccountId, eventSource, eventName, awsRegion, tlsDetails.tlsVersion, tlsDetails.cipherSuite, userAgent + | sort eventSource, eventName, awsRegion, tlsDetails.tlsVersion`, + }, + { + title: 'Find the number of calls per service that used TLS versions 1.0 or 1.1', + expr: stripIndents`filter tlsDetails.tlsVersion in [ "TLSv1", "TLSv1.1" ] + | stats count(*) as numOutdatedTlsCalls by eventSource + | sort numOutdatedTlsCalls desc`, + }, ], }, { @@ -112,6 +145,49 @@ const CLIQ_EXAMPLES: QueryExample[] = [ title: 'List of log events that are not exceptions', expr: 'fields @message | filter @message not like /Exception/', }, + { + title: 'To parse and count fields', + expr: stripIndents`fields @timestamp, @message + | filter @message like /User ID/ + | parse @message "User ID: *" as @userId + | stats count(*) by @userId`, + }, + { + title: 'To Identify faults on any API calls', + expr: stripIndents`filter Operation = AND Fault > 0 + | fields @timestamp, @logStream as instanceId, ExceptionMessage`, + }, + { + title: + 'To get the number of exceptions logged every 5 minutes using regex where exception is not case sensitive', + expr: stripIndents`filter @message like /(?i)Exception/ + | stats count(*) as exceptionCount by bin(5m) + | sort exceptionCount desc`, + }, + { + title: 'To parse ephemeral fields using a glob expression', + expr: stripIndents`parse @message "user=*, method:*, latency := *" as @user, @method, @latency + | stats avg(@latency) by @method, @user`, + }, + { + title: 'To parse ephemeral fields using a glob expression using regular expression', + expr: stripIndents`parse @message /user=(?.*?), method:(?.*?), latency := (?.*?)/ + | stats avg(latency2) by @method2, @user2`, + }, + { + title: 'To extract ephemeral fields and display field for events that contain an ERROR string', + expr: stripIndents`fields @message + | parse @message "* [*] *" as loggingTime, loggingType, loggingMessage + | filter loggingType IN ["ERROR"] + | display loggingMessage, loggingType = "ERROR" as isError`, + }, + { + title: 'To trim whitespaces from query results', + expr: stripIndents`fields trim(@message) as trimmedMessage + | parse trimmedMessage "[*] * * Retrieving CloudWatch Metrics for AccountID : *, CloudWatch Metric : *, Resource Type : *, ResourceID : *" as level, time, logId, accountId, metric, type, resourceId + | display level, time, logId, accountId, metric, type, resourceId + | filter level like "INFO"`, + }, ], }, { @@ -126,7 +202,7 @@ const CLIQ_EXAMPLES: QueryExample[] = [ expr: 'filter responseCode="SERVFAIL" | stats count(*) by queryName', }, { - title: 'Number of requests received every 10 minutes by edge location', + title: 'Top 10 DNS resolver IPs with highest number of requests', expr: 'stats count(*) as numRequests by resolverIp | sort numRequests desc | limit 10', }, ], @@ -193,6 +269,68 @@ const CLIQ_EXAMPLES: QueryExample[] = [ }, ]; +const COMMANDS: QueryExample[] = [ + { + category: 'fields', + examples: [ + { + description: + 'Retrieve one or more log fields. You can also use functions and operations such as abs(a+b), sqrt(a/b), log(a)+log(b), strlen(trim()), datefloor(), isPresent(), and others in this command.', + expr: 'fields @log, @logStream, @message, @timestamp', + }, + ], + }, + { + category: 'filter', + examples: [ + { + description: + 'Retrieve log fields based on one or more conditions. You can use comparison operators such as =, !=, >, >=, <, <=, boolean operators such as and, or, and not, and regular expressions in this command.', + expr: 'filter @message like /(?i)(Exception|error|fail|5dd)/', + }, + ], + }, + { + category: 'stats', + examples: [ + { + description: 'Calculate aggregate statistics such as sum(), avg(), count(), min() and max() for log fields.', + expr: 'stats count() by bin(5m)', + }, + ], + }, + { + category: 'sort', + examples: [ + { + description: 'Sort the log fields in ascending or descending order.', + expr: 'sort @timestamp asc', + }, + ], + }, + { + category: 'limit', + examples: [ + { + description: 'Limit the number of log events returned by a query.', + expr: 'limit 10', + }, + ], + }, + { + category: 'parse', + examples: [ + { + description: + 'Create one or more ephemeral fields, which can be further processed by the query. The following example will extract the ephemeral fields host, identity, dateTimeString, httpVerb, url, protocol, statusCode, bytes from @message, and return the url, max(bytes), and avg(bytes) fields sorted by max(bytes) in descending order.', + expr: stripIndents`parse '* - * [*] "* * *" * *' as host, identity, dateTimeString, httpVerb, url, protocol, statusCode, bytes + | stats max(bytes) as maxBytes, avg(bytes) by url + | sort maxBytes desc`, + }, + ], + }, +]; + function renderHighlightedMarkup(code: string, keyPrefix: string) { const grammar = tokenizer; const tokens = flattenTokens(Prism.tokenize(code, grammar)); @@ -220,83 +358,104 @@ const link = css` text-decoration: underline; `; -export default class LogsCheatSheet extends PureComponent< - QueryEditorHelpProps, - { userExamples: string[] } -> { - onClickExample(query: CloudWatchQuery) { - this.props.onClickExample(query); - } - renderExpression(expr: string, keyPrefix: string) { - return ( - - ); - } - - renderLogsCheatSheet() { - return ( -
-

CloudWatch Logs Cheat Sheet

- {CLIQ_EXAMPLES.map((cat, i) => ( -
-
{cat.category}
- {cat.examples.map((item, j) => ( -
-

{item.title}

- {this.renderExpression(item.expr, `item-${j}`)} -
- ))} -
- ))} -
- ); - } - - render() { - return ( -
-

CloudWatch Logs cheat sheet

- {CLIQ_EXAMPLES.map((cat, i) => ( + <> + {COMMANDS.map((cat, i) => ( +
+
{cat.category}
+ {cat.examples.map((item, j) => ( +
+

{item.description}

+ +
+ ))} +
+ ))} + + + setIsQueriesOpen(isOpen)} + > + {QUERIES.map((cat, i) => (
{cat.category}
{cat.examples.map((item, j) => (

{item.title}

- {this.renderExpression(item.expr, `item-${j}`)} +
))}
))} -
- If you are seeing masked data, you may have CloudWatch logs data protection enabled.{' '} - - See documentation for details - - . -
+
+
+ Note: If you are seeing masked data, you may have CloudWatch logs data protection enabled.{' '} + + See documentation for details + + .
- ); - } -} +
+ ); +}; + +export default LogsCheatSheet; From 926abcf6aa943662a54c0f82aa39b09f9b512265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 26 Apr 2023 07:03:44 +0200 Subject: [PATCH 411/729] Scenes: Update scenes to v6 (#67110) * Scenes: Update scenes to v6 * Fix test --- package.json | 2 +- public/app/features/scenes/ScenePage.tsx | 3 ++- .../features/scenes/dashboard/DashboardScene.tsx | 14 +------------- .../features/scenes/dashboard/DashboardsLoader.ts | 3 ++- yarn.lock | 12 ++++++------ 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index bc82aae9c75..2ac4b9dc47d 100644 --- a/package.json +++ b/package.json @@ -262,7 +262,7 @@ "@grafana/lezer-logql": "0.1.3", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^0.3.0", + "@grafana/scenes": "^0.6.0", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", "@kusto/monaco-kusto": "5.3.6", diff --git a/public/app/features/scenes/ScenePage.tsx b/public/app/features/scenes/ScenePage.tsx index b755d11491b..eacdd2ae720 100644 --- a/public/app/features/scenes/ScenePage.tsx +++ b/public/app/features/scenes/ScenePage.tsx @@ -1,6 +1,7 @@ // Libraries import React, { useEffect, useState } from 'react'; +import { getUrlSyncManager } from '@grafana/scenes'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getSceneByTitle } from './scenes'; @@ -13,7 +14,7 @@ export const ScenePage = (props: Props) => { useEffect(() => { if (scene && !isInitialized) { - scene.initUrlSync(); + getUrlSyncManager().initSync(scene); setInitialized(true); } }, [isInitialized, scene]); diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 88ab5308405..2f0d2967dc7 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { UrlSyncManager, SceneObjectBase, SceneComponentProps, SceneObject, SceneObjectState } from '@grafana/scenes'; +import { SceneObjectBase, SceneComponentProps, SceneObject, SceneObjectState } from '@grafana/scenes'; import { ToolbarButton, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; @@ -18,18 +18,6 @@ interface DashboardSceneState extends SceneObjectState { export class DashboardScene extends SceneObjectBase { public static Component = DashboardSceneRenderer; - private urlSyncManager?: UrlSyncManager; - - /** - * It's better to do this before activate / mount to not trigger unnessary re-renders - */ - public initUrlSync() { - if (!this.urlSyncManager) { - this.urlSyncManager = new UrlSyncManager(this); - } - - this.urlSyncManager.initSync(); - } } function DashboardSceneRenderer({ model }: SceneComponentProps) { diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 0d3c537e1dd..ef173ca8595 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -23,6 +23,7 @@ import { SceneDataTransformer, SceneGridItem, SceneDataProvider, + getUrlSyncManager, } from '@grafana/scenes'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; @@ -75,7 +76,7 @@ export class DashboardLoader extends StateManagerBase { // We initialize URL sync here as it better to do that before mounting and doing any rendering. // But would be nice to have a conditional around this so you can pre-load dashboards without url sync. - dashboard.initUrlSync(); + getUrlSyncManager().initSync(dashboard); this.cache[rsp.dashboard.uid] = dashboard; this.setState({ dashboard, isLoading: false }); diff --git a/yarn.lock b/yarn.lock index e36c41e01f9..20f98a90e43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3342,9 +3342,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:^0.3.0": - version: 0.0.0-use.local - resolution: "@grafana/scenes@npm:0.3.0" +"@grafana/scenes@npm:^0.6.0": + version: 0.6.0 + resolution: "@grafana/scenes@npm:0.6.0" dependencies: "@grafana/e2e-selectors": canary "@grafana/experimental": 1.0.1 @@ -3352,9 +3352,9 @@ __metadata: react-use: 17.4.0 react-virtualized-auto-sizer: 1.0.7 uuid: ^9.0.0 - checksum: 3610cedcc150b9d6e3d6948056bb1bbbfe58d7fa0ff6e762eec6619bb0940504db867ea87e56160f27e4d93772f6203493bf87d353a1b18d2664e74a03f03a05 + checksum: 7197abac93ba84711900b526f0caa648b0b9f0c0e2edea2fbc125c1f192d6a3dae52389007cf82cbdf06a7b8019e5c03b5efa635f12ebc51eab5852cddf6427e languageName: node - linkType: soft + linkType: hard "@grafana/schema@10.0.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": version: 0.0.0-use.local @@ -20169,7 +20169,7 @@ __metadata: "@grafana/lezer-logql": 0.1.3 "@grafana/monaco-logql": ^0.0.7 "@grafana/runtime": "workspace:*" - "@grafana/scenes": ^0.3.0 + "@grafana/scenes": ^0.6.0 "@grafana/schema": "workspace:*" "@grafana/toolkit": "workspace:*" "@grafana/tsconfig": ^1.2.0-rc1 From 044d7f61c7e507845eb476e9e306957ee0b9295c Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 26 Apr 2023 09:08:32 +0200 Subject: [PATCH 412/729] DataSourcePicker: fix flickering datasource dropdown (#67206) * fix flickering * refactor onClose/onOpen * do not set value of input, make the placeholder look like the value instead * Show search icon when the dropdown is open --------- Co-authored-by: Ivan Ortega --- .../components/picker/DataSourceDropdown.tsx | 103 +++++++++--------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 29c9c63b814..2ae3ac8900f 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import React, { useCallback, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; @@ -37,6 +36,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { const openDropdown = () => { reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); setOpen(true); + markerElement?.focus(); }; const currentDataSourceInstanceSettings = useDatasource(current); @@ -45,13 +45,15 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { placement: 'bottom-start', }); + const onClose = useCallback(() => { + setOpen(false); + markerElement?.blur(); + }, [setOpen, markerElement]); + const ref = useRef(null); const { overlayProps, underlayProps } = useOverlay( { - onClose: () => { - setFilterTerm(undefined); - setOpen(false); - }, + onClose: onClose, isDismissable: true, isOpen, shouldCloseOnInteractOutside: (element) => { @@ -66,56 +68,46 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { return (
+
+ + ) : ( + + ) + } + suffix={} + placeholder={dataSourceLabel(currentDataSourceInstanceSettings)} + onFocus={openDropdown} + onClick={openDropdown} + onChange={(e) => { + setFilterTerm(e.currentTarget.value); + }} + ref={setMarkerElement} + > +
{isOpen ? ( - - - ) : ( - - ) - } - suffix={} - placeholder={dataSourceLabel(currentDataSourceInstanceSettings)} - onChange={(e) => { - setFilterTerm(e.currentTarget.value); - }} - ref={setMarkerElement} - > - -
-
- ) => { - setFilterTerm(undefined); - setOpen(false); - onChange(ds); - }} - onClose={() => { - setOpen(false); - }} - current={currentDataSourceInstanceSettings} - style={popper.styles.popper} - ref={setSelectorElement} - {...restProps} - onDismiss={() => {}} - > -
- - - ) : ( -
- } - suffix={} - value={dataSourceLabel(currentDataSourceInstanceSettings)} - onFocus={openDropdown} - /> -
- )} + +
+
+ ) => { + onClose(); + onChange(ds); + }} + onClose={onClose} + current={currentDataSourceInstanceSettings} + style={popper.styles.popper} + ref={setSelectorElement} + {...restProps} + onDismiss={onClose} + > +
+ + ) : null}
); } @@ -132,6 +124,9 @@ function getStylesDropdown(theme: GrafanaTheme2) { input { cursor: pointer; } + input::placeholder { + color: ${theme.colors.text.primary}; + } `, }; } From c41c638b5289557895f9c908d07a5f1d3eb3883b Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Wed, 26 Apr 2023 10:27:37 +0200 Subject: [PATCH 413/729] Alerting: Fix silences preview (#66000) * Use alertmanager /alerts endpoint to show preview of instances affected by silence * Fix debounce dependency, add no instances warning * Rename silences preview component * Fix the preview file name, use IsNulLDate to check the date * Fix valid matchers condition * Cleanup * Remove unused code --- .../alerting/unified/api/alertmanagerApi.ts | 29 ++++ .../unified/components/DynamicTable.tsx | 7 +- .../silences/MatchedSilencedRules.tsx | 128 ----------------- .../silences/SilencedInstancesPreview.tsx | 129 ++++++++++++++++++ .../components/silences/SilencesEditor.tsx | 25 +++- .../alerting/unified/utils/matchers.test.ts | 59 +------- .../alerting/unified/utils/matchers.ts | 63 +-------- 7 files changed, 187 insertions(+), 253 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx create mode 100644 public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 66f6d760422..762c79bb662 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -1,10 +1,13 @@ import { + AlertmanagerAlert, AlertmanagerChoice, AlertManagerCortexConfig, ExternalAlertmanagerConfig, ExternalAlertmanagers, ExternalAlertmanagersResponse, + Matcher, } from '../../../../plugins/datasource/alertmanager/types'; +import { matcherToOperator } from '../utils/alertmanager'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { alertingApi } from './alertingApi'; @@ -16,8 +19,34 @@ export interface AlertmanagersChoiceResponse { numExternalAlertmanagers: number; } +interface AlertmanagerAlertsFilter { + active?: boolean; + silenced?: boolean; + inhibited?: boolean; + unprocessed?: boolean; + matchers?: Matcher[]; +} + +// Based on https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml export const alertmanagerApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ + getAlertmanagerAlerts: build.query< + AlertmanagerAlert[], + { amSourceName: string; filter?: AlertmanagerAlertsFilter } + >({ + query: ({ amSourceName, filter }) => { + // TODO Add support for active, silenced, inhibited, unprocessed filters + const filterMatchers = filter?.matchers + ?.filter((matcher) => matcher.name && matcher.value) + .map((matcher) => `${matcher.name}${matcherToOperator(matcher)}${matcher.value}`); + + return { + url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts`, + params: { filter: filterMatchers }, + }; + }, + }), + getAlertmanagerChoiceStatus: build.query({ query: () => ({ url: '/api/v1/ngalert' }), providesTags: ['AlertmanagerChoice'], diff --git a/public/app/features/alerting/unified/components/DynamicTable.tsx b/public/app/features/alerting/unified/components/DynamicTable.tsx index deae6c1a7eb..29ee2eeac96 100644 --- a/public/app/features/alerting/unified/components/DynamicTable.tsx +++ b/public/app/features/alerting/unified/components/DynamicTable.tsx @@ -17,6 +17,7 @@ export interface DynamicTableColumnProps { renderCell: (item: DynamicTableItemProps, index: number) => ReactNode; size?: number | string; + className?: string; } export interface DynamicTableItemProps { @@ -134,7 +135,11 @@ export const DynamicTable = ({
)} {cols.map((col) => ( -
+
{col.renderCell(item, index)}
))} diff --git a/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx b/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx deleted file mode 100644 index 3042d066ec5..00000000000 --- a/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { useDebounce } from 'react-use'; - -import { dateTime, GrafanaTheme2 } from '@grafana/data'; -import { Badge, useStyles2 } from '@grafana/ui'; -import { useDispatch } from 'app/types'; -import { Alert, AlertingRule } from 'app/types/unified-alerting'; - -import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; -import { fetchAllPromAndRulerRulesAction } from '../../state/actions'; -import { MatcherFieldValue, SilenceFormFields } from '../../types/silence-form'; -import { findAlertInstancesWithMatchers } from '../../utils/matchers'; -import { isAlertingRule } from '../../utils/rules'; -import { AlertLabels } from '../AlertLabels'; -import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; -import { AlertStateTag } from '../rules/AlertStateTag'; - -type MatchedRulesTableItemProps = DynamicTableItemProps<{ - matchedInstance: Alert; -}>; -type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedInstance: Alert }>; - -export const MatchedSilencedRules = () => { - const [matchedAlertRules, setMatchedAlertRules] = useState([]); - const formApi = useFormContext(); - const dispatch = useDispatch(); - const { watch } = formApi; - const matchers: MatcherFieldValue[] = watch('matchers'); - const styles = useStyles2(getStyles); - const columns = useColumns(); - - useEffect(() => { - dispatch(fetchAllPromAndRulerRulesAction()); - }, [dispatch]); - - const combinedNamespaces = useCombinedRuleNamespaces(); - useDebounce( - () => { - const matchedInstances = combinedNamespaces.flatMap((namespace) => { - return namespace.groups.flatMap((group) => { - return group.rules - .map((combinedRule) => combinedRule.promRule) - .filter((rule): rule is AlertingRule => isAlertingRule(rule)) - .flatMap((rule) => findAlertInstancesWithMatchers(rule.alerts ?? [], matchers)); - }); - }); - setMatchedAlertRules(matchedInstances); - }, - 500, - [combinedNamespaces, matchers] - ); - - return ( -
-

- Affected alert instances - {matchedAlertRules.length > 0 ? ( - - ) : null} -

-
- {matchers.every((matcher) => !matcher.value && !matcher.name) ? ( - Add a valid matcher to see affected alerts - ) : ( - - )} -
-
- ); -}; - -function useColumns(): MatchedRulesTableColumnProps[] { - return [ - { - id: 'state', - label: 'State', - renderCell: function renderStateTag({ data: { matchedInstance } }) { - return ; - }, - size: '160px', - }, - { - id: 'labels', - label: 'Labels', - renderCell: function renderName({ data: { matchedInstance } }) { - return ; - }, - size: 'auto', - }, - { - id: 'created', - label: 'Created', - renderCell: function renderSummary({ data: { matchedInstance } }) { - return ( - <> - {matchedInstance.activeAt.startsWith('0001') - ? '-' - : dateTime(matchedInstance.activeAt).format('YYYY-MM-DD HH:mm:ss')} - - ); - }, - size: '180px', - }, - ]; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css` - max-width: ${theme.breakpoints.values.lg}px; - `, - moreMatches: css` - margin-top: ${theme.spacing(1)}; - `, - title: css` - display: flex; - align-items: center; - `, - badge: css` - margin-left: ${theme.spacing(1)}; - `, -}); diff --git a/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx b/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx new file mode 100644 index 00000000000..8c1b4490b29 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx @@ -0,0 +1,129 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { dateTime, GrafanaTheme2 } from '@grafana/data'; +import { Alert, Badge, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types'; + +import { alertmanagerApi } from '../../api/alertmanagerApi'; +import { isNullDate } from '../../utils/time'; +import { AlertLabels } from '../AlertLabels'; +import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; + +import { AmAlertStateTag } from './AmAlertStateTag'; + +interface Props { + amSourceName: string; + matchers: Matcher[]; +} + +export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => { + const { useGetAlertmanagerAlertsQuery } = alertmanagerApi; + const styles = useStyles2(getStyles); + const columns = useColumns(); + + // By default the form contains an empty matcher - with empty name and value and = operator + // We don't want to fetch previews for empty matchers as it results in all alerts returned + const hasValidMatchers = matchers.some((matcher) => matcher.value && matcher.name); + + const { + currentData: alerts = [], + isFetching, + isError, + } = useGetAlertmanagerAlertsQuery( + { amSourceName, filter: { matchers } }, + { skip: !hasValidMatchers, refetchOnMountOrArgChange: true } + ); + + const tableItemAlerts = alerts.map>((alert) => ({ + id: alert.fingerprint, + data: alert, + })); + + return ( +
+

+ Affected alert instances + {tableItemAlerts.length > 0 ? ( + + ) : null} +

+ {!hasValidMatchers && Add a valid matcher to see affected alerts} + {isError && ( + + Error occured when generating affected alerts preview. Are you matchers valid? + + )} + {isFetching && } + {!isFetching && !isError && hasValidMatchers && ( +
+ {tableItemAlerts.length > 0 ? ( + + ) : ( + No matching alert instances found + )} +
+ )} +
+ ); +}; + +function useColumns(): Array> { + const styles = useStyles2(getStyles); + + return [ + { + id: 'state', + label: 'State', + renderCell: function renderStateTag({ data }) { + return ; + }, + size: '120px', + className: styles.stateColumn, + }, + { + id: 'labels', + label: 'Labels', + renderCell: function renderName({ data }) { + return ; + }, + size: 'auto', + }, + { + id: 'created', + label: 'Created', + renderCell: function renderSummary({ data }) { + return <>{isNullDate(data.startsAt) ? '-' : dateTime(data.startsAt).format('YYYY-MM-DD HH:mm:ss')}; + }, + size: '180px', + }, + ]; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css` + max-width: ${theme.breakpoints.values.lg}px; + `, + moreMatches: css` + margin-top: ${theme.spacing(1)}; + `, + title: css` + display: flex; + align-items: center; + `, + badge: css` + margin-left: ${theme.spacing(1)}; + `, + stateColumn: css` + display: flex; + align-items: center; + `, + alertLabels: css` + justify-content: flex-start; + `, +}); diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 3d1bb015b9d..7f6c3269146 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { pickBy } from 'lodash'; +import { isEqual, pickBy } from 'lodash'; import React, { useMemo, useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { useDebounce } from 'react-use'; @@ -16,7 +16,7 @@ import { import { config } from '@grafana/runtime'; import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; @@ -28,9 +28,9 @@ import { parseQueryParamMatchers } from '../../utils/matchers'; import { makeAMLink } from '../../utils/misc'; import { initialAsyncRequestState } from '../../utils/redux'; -import { MatchedSilencedRules } from './MatchedSilencedRules'; import MatchersField from './MatchersField'; import { SilencePeriod } from './SilencePeriod'; +import { SilencedInstancesPreview } from './SilencedInstancesPreview'; interface Props { silence?: Silence; @@ -104,6 +104,9 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { const formAPI = useForm({ defaultValues }); const dispatch = useDispatch(); const styles = useStyles2(getStyles); + const [matchersForPreview, setMatchersForPreview] = useState( + defaultValues.matchers.map(matcherFieldToMatcher) + ); const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence); @@ -138,6 +141,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { const duration = watch('duration'); const startsAt = watch('startsAt'); const endsAt = watch('endsAt'); + const matcherFields = watch('matchers'); // Keep duration and endsAt in sync const [prevDuration, setPrevDuration] = useState(duration); @@ -164,6 +168,19 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { 700, [clearErrors, duration, endsAt, prevDuration, setValue, startsAt] ); + + useDebounce( + () => { + // React-hook-form watch does not return referentialy equal values so this trick is needed + const newMatchers = matcherFields.filter((m) => m.name && m.value).map(matcherFieldToMatcher); + if (!isEqual(matchersForPreview, newMatchers)) { + setMatchersForPreview(newMatchers); + } + }, + 700, + [matcherFields] + ); + const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name); return ( @@ -221,7 +238,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { /> )} - +
{loading && ( diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 09a54217fb7..ae5b64b105e 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,8 +1,4 @@ -import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; - -import { mockPromAlert } from '../mocks'; - -import { getMatcherQueryParams, findAlertInstancesWithMatchers, parseQueryParamMatchers } from './matchers'; +import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -37,57 +33,4 @@ describe('Unified Alerting matchers', () => { expect(matchers[0].value).toBe('TestData 1'); }); }); - - describe('matchLabelsToMatchers', () => { - it('should match for equal', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.equal }]; - const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })]; - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - - expect(matchedAlerts).toHaveLength(1); - }); - - it('should match for not equal', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notEqual }]; - const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(1); - }); - - it('should match for regex', () => { - const matchers = [{ name: 'foo', value: 'b{1}a.*', operator: MatcherOperator.regex }]; - const alerts = [ - mockPromAlert({ labels: { foo: 'bbr' } }), - mockPromAlert({ labels: { foo: 'aba' } }), // This does not match because the regex is implicitly anchored. - mockPromAlert({ labels: { foo: 'ba' } }), - mockPromAlert({ labels: { foo: 'bar' } }), - mockPromAlert({ labels: { foo: 'baz' } }), - mockPromAlert({ labels: { foo: 'bas' } }), - ]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(4); - expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual([ - 'ba', - 'bar', - 'baz', - 'bas', - ]); - }); - - it('should not match regex', () => { - const matchers = [{ name: 'foo', value: 'ba{3}', operator: MatcherOperator.notRegex }]; - const alerts = [ - mockPromAlert({ labels: { foo: 'bar' } }), - mockPromAlert({ labels: { foo: 'baz' } }), - mockPromAlert({ labels: { foo: 'baaa' } }), - mockPromAlert({ labels: { foo: 'bas' } }), - ]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(3); - expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual(['bar', 'baz', 'bas']); - }); - }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index d8889638f58..7ca37819719 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -1,10 +1,7 @@ import { uniqBy } from 'lodash'; import { Labels } from '@grafana/data'; -import { Matcher, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; -import { Alert } from 'app/types/unified-alerting'; - -import { MatcherFieldValue } from '../types/silence-form'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { parseMatcher } from './alertmanager'; @@ -29,61 +26,3 @@ export const getMatcherQueryParams = (labels: Labels) => { return matcherUrlParams; }; - -interface MatchedInstance { - id: string; - data: { - matchedInstance: Alert; - }; -} - -export const findAlertInstancesWithMatchers = ( - instances: Alert[], - matchers: MatcherFieldValue[] -): MatchedInstance[] => { - const anchorRegex = (regexpString: string): RegExp => { - // Silence matchers are always fully anchored in the Alertmanager: https://github.com/prometheus/alertmanager/pull/748 - if (!regexpString.startsWith('^')) { - regexpString = '^' + regexpString; - } - if (!regexpString.endsWith('$')) { - regexpString = regexpString + '$'; - } - return new RegExp(regexpString); - }; - - const matchesInstance = (instance: Alert, matcher: MatcherFieldValue) => { - return Object.entries(instance.labels).some(([key, value]) => { - if (!matcher.name || !matcher.value) { - return false; - } - if (matcher.name !== key) { - return false; - } - switch (matcher.operator) { - case MatcherOperator.equal: - return matcher.value === value; - case MatcherOperator.notEqual: - return matcher.value !== value; - case MatcherOperator.regex: - const regex = anchorRegex(matcher.value); - return regex.test(value); - case MatcherOperator.notRegex: - const negregex = anchorRegex(matcher.value); - return !negregex.test(value); - default: - return false; - } - }); - }; - - const filteredInstances = instances.filter((instance) => { - return matchers.every((matcher) => matchesInstance(instance, matcher)); - }); - const mappedInstances = filteredInstances.map((instance) => ({ - id: `${instance.activeAt}-${instance.value}`, - data: { matchedInstance: instance }, - })); - - return mappedInstances; -}; From 106eceab55627cb7d41acab2517f39a0d344e1bb Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 26 Apr 2023 11:03:07 +0200 Subject: [PATCH 414/729] Loki: Remove experimental badge for context ui (#67219) * Loki: Remove experimental badge * Remove unused styles --- .../datasource/loki/components/LokiContextUi.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx index 36211d885db..73bfc016bbd 100644 --- a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx @@ -4,7 +4,7 @@ import { useAsync } from 'react-use'; import { GrafanaTheme2, LogRowModel, SelectableValue } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { Collapse, Icon, Label, MultiSelect, Tag, Tooltip, useStyles2 } from '@grafana/ui'; +import { Collapse, Icon, Label, MultiSelect, Tooltip, useStyles2 } from '@grafana/ui'; import store from 'app/core/store'; import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; @@ -41,9 +41,6 @@ function getStyles(theme: GrafanaTheme2) { hidden: css` visibility: hidden; `, - tag: css` - padding: ${theme.spacing(0.25)} ${theme.spacing(0.75)}; - `, label: css` max-width: 100%; margin: ${theme.spacing(2)} 0; @@ -190,14 +187,6 @@ export function LokiContextUi(props: LokiContextUiProps) { } >
- - - {' '}
); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 3e6a31f5b5a..89da60bb032 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -17,6 +17,7 @@ export interface OwnProps { dashboard: DashboardModel; isEditing: boolean; isViewing: boolean; + isDraggable?: boolean; width: number; height: number; lazy?: boolean; @@ -72,7 +73,18 @@ export class DashboardPanelUnconnected extends PureComponent { }; renderPanel = ({ isInView }: { isInView: boolean }) => { - const { dashboard, panel, isViewing, isEditing, width, height, plugin, timezone, hideMenu } = this.props; + const { + dashboard, + panel, + isViewing, + isEditing, + width, + height, + plugin, + timezone, + hideMenu, + isDraggable = true, + } = this.props; if (!plugin) { return null; @@ -87,6 +99,7 @@ export class DashboardPanelUnconnected extends PureComponent { isViewing={isViewing} isEditing={isEditing} isInView={isInView} + isDraggable={isDraggable} width={width} height={height} /> @@ -101,6 +114,7 @@ export class DashboardPanelUnconnected extends PureComponent { isViewing={isViewing} isEditing={isEditing} isInView={isInView} + isDraggable={isDraggable} width={width} height={height} onInstanceStateChange={this.onInstanceStateChange} diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index 47d9f581ef9..435341e227d 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -28,6 +28,7 @@ interface OwnProps { isViewing: boolean; isEditing: boolean; isInView: boolean; + isDraggable?: boolean; width: number; height: number; hideMenu?: boolean; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index a05518aacde..a593580c61e 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -65,6 +65,7 @@ export interface Props { isViewing: boolean; isEditing: boolean; isInView: boolean; + isDraggable?: boolean; width: number; height: number; onInstanceStateChange: (value: any) => void; diff --git a/public/app/features/dashboard/utils/getPanelChromeProps.tsx b/public/app/features/dashboard/utils/getPanelChromeProps.tsx index fb4a68d5c99..90cf4261878 100644 --- a/public/app/features/dashboard/utils/getPanelChromeProps.tsx +++ b/public/app/features/dashboard/utils/getPanelChromeProps.tsx @@ -17,6 +17,7 @@ interface CommonProps { isViewing: boolean; isEditing: boolean; isInView: boolean; + isDraggable?: boolean; width: number; height: number; hideMenu?: boolean; @@ -100,7 +101,8 @@ export function getPanelChromeProps(props: CommonProps) { const description = props.panel.description ? onShowPanelDescription() : undefined; - const dragClass = !(props.isViewing || props.isEditing) ? 'grid-drag-handle' : ''; + const dragClass = + !(props.isViewing || props.isEditing) && Boolean(props.isDraggable ?? true) ? 'grid-drag-handle' : ''; const title = props.panel.getDisplayTitle(); From 9599e8003b3476dbc6629d43b60e5c9eeee45b85 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed, 26 Apr 2023 15:43:20 +0300 Subject: [PATCH 426/729] Datagrid: Switch to panel context update (#67221) * WIP * Switch Datagrid to use PanelContext onUpdateData * PR modifications * refactor * block panel if not enabled --- .../datagrid_metric_values.json | 1 + .../datagrid-data-change.spec.ts | 8 +- .../datagrid-editing-features.spec.ts | 13 ++- .../app/plugins/datasource/grafana/utils.ts | 2 +- .../panel/datagrid/DataGridPanel.test.tsx | 38 ++++---- .../plugins/panel/datagrid/DataGridPanel.tsx | 95 ++++++++++++------- .../panel/datagrid/featureFlagUtils.tsx | 2 +- public/app/plugins/panel/datagrid/state.ts | 4 +- .../app/plugins/panel/datagrid/utils.test.ts | 2 +- public/app/plugins/panel/datagrid/utils.ts | 92 +++++++++--------- 10 files changed, 147 insertions(+), 110 deletions(-) diff --git a/devenv/dev-dashboards/panel-datagrid/datagrid_metric_values.json b/devenv/dev-dashboards/panel-datagrid/datagrid_metric_values.json index 9ee8da0fd5d..6fb2f433102 100644 --- a/devenv/dev-dashboards/panel-datagrid/datagrid_metric_values.json +++ b/devenv/dev-dashboards/panel-datagrid/datagrid_metric_values.json @@ -75,5 +75,6 @@ "timezone": "", "title": "Datagrid example", "version": 0, + "uid": "c01bf42b-b783-4447-a304-8554cee1843b", "weekStart": "" } \ No newline at end of file diff --git a/e2e/datagrid-suite/datagrid-data-change.spec.ts b/e2e/datagrid-suite/datagrid-data-change.spec.ts index df61fca4751..f4409e32220 100644 --- a/e2e/datagrid-suite/datagrid-data-change.spec.ts +++ b/e2e/datagrid-suite/datagrid-data-change.spec.ts @@ -1,6 +1,6 @@ import { e2e } from '@grafana/e2e'; -const DASHBOARD_ID = 'a70ecb44-6c31-412d-ae74-d6306303ce37'; +const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; const DATAGRID_SELECT_SERIES = 'Datagrid Select series'; e2e.scenario({ @@ -27,8 +27,10 @@ e2e.scenario({ // Edit datagrid which triggers a snapshot query cy.get('.dvn-scroller').click(200, 100); cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('body').type('123455{enter}', { delay: 1000 }); + cy.get('body').type('12{enter}', { delay: 500 }); - cy.get('[data-testid="query-editor-row"]').contains('Spreadsheet or snapshot'); + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); + + cy.get('[data-testid="query-editor-row"]').contains('Snapshot'); }, }); diff --git a/e2e/datagrid-suite/datagrid-editing-features.spec.ts b/e2e/datagrid-suite/datagrid-editing-features.spec.ts index f1972fd6457..e71d1f31967 100644 --- a/e2e/datagrid-suite/datagrid-editing-features.spec.ts +++ b/e2e/datagrid-suite/datagrid-editing-features.spec.ts @@ -1,6 +1,6 @@ import { e2e } from '@grafana/e2e'; -const DASHBOARD_ID = 'a70ecb44-6c31-412d-ae74-d6306303ce37'; +const DASHBOARD_ID = 'c01bf42b-b783-4447-a304-8554cee1843b'; const DATAGRID_CANVAS = 'data-grid-canvas'; e2e.scenario({ @@ -15,7 +15,9 @@ e2e.scenario({ // Edit datagrid which triggers a snapshot query cy.get('.dvn-scroller').click(200, 100); cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true'); - cy.get('body').type('1{enter}'); + cy.get('body').type('123{enter}', { delay: 500 }); + + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); // Delete a cell cy.get('.dvn-scroller').click(200, 200); @@ -55,6 +57,7 @@ e2e.scenario({ cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); cy.get('.dvn-scroller').click(20, 90, { shiftKey: true, waitForAnimations: true }); // with shift to select all rows between clicks cy.get('body').type('{del}'); + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); cy.get('[data-testid="glide-cell-1-4"]').should('have.text', ''); cy.get('[data-testid="glide-cell-1-3"]').should('have.text', ''); cy.get('[data-testid="glide-cell-1-2"]').should('have.text', ''); @@ -68,6 +71,9 @@ e2e.scenario({ cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true }); cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with cmd to select only clicked rows cy.get('body').type('{del}'); + + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); + cy.get('[data-testid="glide-cell-1-1"]').should('have.text', ''); cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0); cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0); @@ -83,6 +89,7 @@ e2e.scenario({ // Delete column through header dropdown menu cy.get('.dvn-scroller').click(250, 15); // click header dropdown cy.get('body').click(450, 420); // click delete column + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 1); // Delete row through context menu @@ -101,6 +108,7 @@ e2e.scenario({ cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with shift to select all rows between clicks cy.get('.dvn-scroller').rightclick(40, 90); cy.get('[aria-label="Context menu"]').click(10, 10); + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 5); // there are 5 data rows + 1 for the add new row btns // Delete column through context menu @@ -113,6 +121,7 @@ e2e.scenario({ // Add a new column cy.get('body').click(350, 200).type('New Column{enter}'); + cy.get('[aria-label="Confirm Modal Danger Button"]').click(); cy.get('body') .click(350, 230) .type('Value 1{enter}') diff --git a/public/app/plugins/datasource/grafana/utils.ts b/public/app/plugins/datasource/grafana/utils.ts index ec9b6ff321c..1e7972f5888 100644 --- a/public/app/plugins/datasource/grafana/utils.ts +++ b/public/app/plugins/datasource/grafana/utils.ts @@ -22,7 +22,7 @@ export function onUpdatePanelSnapshotData(panel: PanelModel, frames: DataFrame[] appEvents.publish( new ShowConfirmModalEvent({ title: 'Change to panel embedded data', - text: 'If you want to change the data shown in this panel Grafana will need to remove the panels current query and replace it with a snapshot of the current data. This enabled you to edit the data', + text: 'If you want to change the data shown in this panel Grafana will need to remove the panels current query and replace it with a snapshot of the current data. This enables you to edit the data.', yesText: 'Continue', icon: 'pen', onConfirm: () => { diff --git a/public/app/plugins/panel/datagrid/DataGridPanel.test.tsx b/public/app/plugins/panel/datagrid/DataGridPanel.test.tsx index 49bc1ffae58..d2e13269bf7 100644 --- a/public/app/plugins/panel/datagrid/DataGridPanel.test.tsx +++ b/public/app/plugins/panel/datagrid/DataGridPanel.test.tsx @@ -1,14 +1,14 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { ArrayVector, DataFrame, dateTime, EventBus, FieldType, LoadingState, MutableDataFrame } from '@grafana/data'; +import { ArrayVector, DataFrame, dateTime, EventBus, Field, FieldType, LoadingState } from '@grafana/data'; import { DataGridPanel, DataGridProps } from './DataGridPanel'; import * as utils from './utils'; jest.mock('./featureFlagUtils', () => { return { - isDatagridEditEnabled: jest.fn().mockReturnValue(true), + isDatagridEnabled: jest.fn().mockReturnValue(true), }; }); @@ -18,7 +18,7 @@ jest.mock('./utils', () => { ...originalModule, deleteRows: jest.fn(), clearCellsFromRangeSelection: jest.fn(), - publishSnapshot: jest.fn(), + updateSnapshot: jest.fn(), }; }); @@ -192,7 +192,7 @@ describe('DataGrid', () => { }); it('editing a cell triggers publishing the snapshot', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, @@ -215,7 +215,7 @@ describe('DataGrid', () => { const expectedField = { ...props.data.series[0].fields[0], }; - expectedField.values = new ArrayVector([1, 9, 3, 4]); + expectedField.values = [1, 9, 3, 4]; await waitFor(() => { const overlay = screen.getByDisplayValue('9'); @@ -234,7 +234,7 @@ describe('DataGrid', () => { expect.objectContaining({ fields: expect.arrayContaining([expectedField]), }), - 1 + undefined ); }); }); @@ -272,7 +272,7 @@ describe('DataGrid', () => { }); }); it('should add a new column', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, @@ -301,11 +301,12 @@ describe('DataGrid', () => { }), ]), }), - 1 + undefined ); }); + it('should not add a new column if input is empty', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, @@ -322,8 +323,9 @@ describe('DataGrid', () => { expect(spy).not.toBeCalled(); }); + it('should add a new row', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, @@ -368,9 +370,9 @@ describe('DataGrid', () => { }); it('should clear cell when cell is selected and delete button clicked', async () => { + const spy = jest.spyOn(utils, 'updateSnapshot'); const spyClearingCells = jest.spyOn(utils, 'clearCellsFromRangeSelection'); const spyDeleteRows = jest.spyOn(utils, 'deleteRows'); - const spy = jest.spyOn(utils, 'publishSnapshot'); jest.useFakeTimers(); render(, { @@ -398,9 +400,9 @@ describe('DataGrid', () => { }); it('should clear row when row is selected delete button clicked', async () => { + const spy = jest.spyOn(utils, 'updateSnapshot'); const spyClearingCells = jest.spyOn(utils, 'clearCellsFromRangeSelection'); const spyDeleteRows = jest.spyOn(utils, 'deleteRows'); - const spy = jest.spyOn(utils, 'publishSnapshot'); jest.useFakeTimers(); render(, { @@ -424,8 +426,7 @@ describe('DataGrid', () => { }); it('should move column when column dragged and dropped', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); - + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, @@ -446,17 +447,18 @@ describe('DataGrid', () => { fireEvent.mouseUp(canvas); - const df = new MutableDataFrame(props.data.series[0]); + const df = { + ...props.data.series[0], + }; df.fields = [df.fields[1], df.fields[0], df.fields[2]]; - const received = spy.mock.calls[spy.mock.calls.length - 1][0].fields.map((f) => f.name); + const received = spy.mock.calls[spy.mock.calls.length - 1][0].fields.map((f: Field) => f.name); expect(received).toEqual(df.fields.map((f) => f.name)); }); it('should move row when row dragged and dropped', async () => { - const spy = jest.spyOn(utils, 'publishSnapshot'); - + const spy = jest.spyOn(utils, 'updateSnapshot'); jest.useFakeTimers(); render(, { wrapper: Context, diff --git a/public/app/plugins/panel/datagrid/DataGridPanel.tsx b/public/app/plugins/panel/datagrid/DataGridPanel.tsx index f99370c49ad..dc72717ce5c 100644 --- a/public/app/plugins/panel/datagrid/DataGridPanel.tsx +++ b/public/app/plugins/panel/datagrid/DataGridPanel.tsx @@ -10,16 +10,16 @@ import DataEditor, { } from '@glideapps/glide-data-grid'; import React, { useEffect, useReducer } from 'react'; -import { Field, PanelProps, FieldType } from '@grafana/data'; +import { Field, PanelProps, FieldType, DataFrame } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; -import { useTheme2 } from '@grafana/ui'; +import { usePanelContext, useTheme2 } from '@grafana/ui'; import '@glideapps/glide-data-grid/dist/index.css'; import { AddColumn } from './components/AddColumn'; import { DatagridContextMenu } from './components/DatagridContextMenu'; import { RenameColumnCell } from './components/RenameColumnCell'; -import { isDatagridEditEnabled } from './featureFlagUtils'; +import { isDatagridEnabled } from './featureFlagUtils'; import { PanelOptions } from './panelcfg.gen'; import { DatagridActionType, datagridReducer, initialState } from './state'; import { @@ -28,19 +28,21 @@ import { EMPTY_CELL, getGridCellKind, getGridTheme, - publishSnapshot, RIGHT_ELEMENT_PROPS, TRAILING_ROW_OPTIONS, getStyles, ROW_MARKER_BOTH, ROW_MARKER_NUMBER, hasGridSelection, + updateSnapshot, } from './utils'; export interface DataGridProps extends PanelProps {} export function DataGridPanel({ options, data, id, fieldConfig, width, height }: DataGridProps) { const [state, dispatch] = useReducer(datagridReducer, initialState); + const { onUpdateData } = usePanelContext(); + const { columns, contextMenuData, @@ -74,9 +76,23 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: return getGridCellKind(field, row, hasGridSelection(gridSelection)); }; - const onCellEdited = (cell: Item, newValue: EditableGridCell) => { + const onCellEdited = async (cell: Item, newValue: EditableGridCell) => { + // if there are rows selected, return early, we don't want to edit any cell + if (hasGridSelection(gridSelection)) { + return; + } + const [col, row] = cell; - const field: Field = frame.fields[col]; + const frameCopy = { + ...frame, + fields: frame.fields.map((f) => { + return { + ...f, + values: [...f.values], + }; + }), + }; + const field: Field = frameCopy.fields[col]; if (!field) { return; @@ -85,14 +101,14 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: const values = field.values.toArray(); values[row] = newValue.data; - field.values = values; + field.values = [...values]; - publishSnapshot(frame, id); + updateSnapshot(frameCopy, onUpdateData); }; const onColumnInputBlur = (columnName: string) => { const len = frame.length ?? 0; - publishSnapshot( + updateSnapshot( { ...frame, fields: [ @@ -105,7 +121,7 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: }, ], }, - id + onUpdateData ); }; @@ -115,7 +131,8 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: values.push(null); return { ...f, values }; }); - publishSnapshot({ ...frame, fields, length: frame.length + 1 }, id); + + updateSnapshot({ ...frame, fields, length: frame.length + 1 }, onUpdateData); }; const onColumnResize = (column: GridColumn, width: number, columnIndex: number, newSizeWithGrow: number) => { @@ -133,12 +150,12 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: const onDeletePressed = (selection: GridSelection) => { if (selection.current && selection.current.range) { - publishSnapshot(clearCellsFromRangeSelection(frame, selection.current.range), id); + updateSnapshot(clearCellsFromRangeSelection(frame, selection.current.range), onUpdateData); return true; } if (selection.rows) { - publishSnapshot(deleteRows(frame, selection.rows.toArray()), id); + updateSnapshot(deleteRows(frame, selection.rows.toArray()), onUpdateData); return true; } @@ -162,14 +179,17 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: }); }; - const onColumnMove = (from: number, to: number) => { + const onColumnMove = async (from: number, to: number) => { const fields = frame.fields.map((f) => f); const field = fields[from]; fields.splice(from, 1); fields.splice(to, 0, field); - dispatch({ type: DatagridActionType.columnMove, payload: { from, to } }); - publishSnapshot({ ...frame, fields }, id); + const hasUpdated = await updateSnapshot({ ...frame, fields }, onUpdateData); + + if (hasUpdated) { + dispatch({ type: DatagridActionType.columnMove, payload: { from, to } }); + } }; const onRowMove = (from: number, to: number) => { @@ -181,7 +201,7 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: field.values.splice(to, 0, value); } - publishSnapshot({ ...frame, fields }, id); + updateSnapshot({ ...frame, fields }, onUpdateData); }; const onColumnRename = () => { @@ -193,7 +213,8 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: fields[columnIdx].name = columnName; dispatch({ type: DatagridActionType.hideColumnRenameInput }); - publishSnapshot({ ...frame, fields }, id); + + updateSnapshot({ ...frame, fields }, onUpdateData); }; const onSearchClose = () => { @@ -204,10 +225,18 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: dispatch({ type: DatagridActionType.multipleCellsSelected, payload: { selection } }); }; + const onContextMenuSave = (data: DataFrame) => { + updateSnapshot(data, onUpdateData); + }; + if (!frame) { return ; } + if (!isDatagridEnabled()) { + return ; + } + if (!document.getElementById('portal')) { const portal = document.createElement('div'); portal.id = 'portal'; @@ -230,37 +259,37 @@ export function DataGridPanel({ options, data, id, fieldConfig, width, height }: smoothScrollX smoothScrollY overscrollY={50} - onCellEdited={isDatagridEditEnabled() ? onCellEdited : undefined} - getCellsForSelection={isDatagridEditEnabled() ? true : undefined} - showSearch={isDatagridEditEnabled() ? toggleSearch : false} + onCellEdited={isDatagridEnabled() ? onCellEdited : undefined} + getCellsForSelection={isDatagridEnabled() ? true : undefined} + showSearch={isDatagridEnabled() ? toggleSearch : false} onSearchClose={onSearchClose} - onPaste={isDatagridEditEnabled() ? true : undefined} + onPaste={isDatagridEnabled() ? true : undefined} gridSelection={gridSelection} - onGridSelectionChange={isDatagridEditEnabled() ? onGridSelectionChange : undefined} - onRowAppended={isDatagridEditEnabled() ? addNewRow : undefined} - onDelete={isDatagridEditEnabled() ? onDeletePressed : undefined} - rowMarkers={isDatagridEditEnabled() ? ROW_MARKER_BOTH : ROW_MARKER_NUMBER} + onGridSelectionChange={isDatagridEnabled() ? onGridSelectionChange : undefined} + onRowAppended={isDatagridEnabled() ? addNewRow : undefined} + onDelete={isDatagridEnabled() ? onDeletePressed : undefined} + rowMarkers={isDatagridEnabled() ? ROW_MARKER_BOTH : ROW_MARKER_NUMBER} onColumnResize={onColumnResize} onColumnResizeEnd={onColumnResizeEnd} - onCellContextMenu={isDatagridEditEnabled() ? onCellContextMenu : undefined} - onHeaderContextMenu={isDatagridEditEnabled() ? onHeaderContextMenu : undefined} - onHeaderMenuClick={isDatagridEditEnabled() ? onHeaderMenuClick : undefined} + onCellContextMenu={isDatagridEnabled() ? onCellContextMenu : undefined} + onHeaderContextMenu={isDatagridEnabled() ? onHeaderContextMenu : undefined} + onHeaderMenuClick={isDatagridEnabled() ? onHeaderMenuClick : undefined} trailingRowOptions={TRAILING_ROW_OPTIONS} rightElement={ - isDatagridEditEnabled() ? ( + isDatagridEnabled() ? ( ) : null } rightElementProps={RIGHT_ELEMENT_PROPS} freezeColumns={columnFreezeIndex} - onRowMoved={isDatagridEditEnabled() ? onRowMove : undefined} - onColumnMoved={isDatagridEditEnabled() ? onColumnMove : undefined} + onRowMoved={isDatagridEnabled() ? onRowMove : undefined} + onColumnMoved={isDatagridEnabled() ? onColumnMove : undefined} /> {contextMenuData.isContextMenuOpen && ( publishSnapshot(data, id)} + saveData={onContextMenuSave} closeContextMenu={closeContextMenu} dispatch={dispatch} gridSelection={gridSelection} diff --git a/public/app/plugins/panel/datagrid/featureFlagUtils.tsx b/public/app/plugins/panel/datagrid/featureFlagUtils.tsx index e2fed546cf0..0b41f53c114 100644 --- a/public/app/plugins/panel/datagrid/featureFlagUtils.tsx +++ b/public/app/plugins/panel/datagrid/featureFlagUtils.tsx @@ -1,5 +1,5 @@ import { config } from '@grafana/runtime'; -export const isDatagridEditEnabled = () => { +export const isDatagridEnabled = () => { return config.featureToggles.enableDatagridEditing; }; diff --git a/public/app/plugins/panel/datagrid/state.ts b/public/app/plugins/panel/datagrid/state.ts index 28d7db07807..65840df7afe 100644 --- a/public/app/plugins/panel/datagrid/state.ts +++ b/public/app/plugins/panel/datagrid/state.ts @@ -10,7 +10,7 @@ import { import { DataFrame, Field, FieldType, getFieldDisplayName } from '@grafana/data'; -import { isDatagridEditEnabled } from './featureFlagUtils'; +import { isDatagridEnabled } from './featureFlagUtils'; import { DatagridContextMenuData, DEFAULT_CONTEXT_MENU, @@ -224,7 +224,7 @@ export const datagridReducer = (state: DatagridState, action: DatagridAction): D title: displayName, width: state.columns[index]?.width ?? getCellWidth(field), icon: typeToIconMap.get(field.type), - hasMenu: isDatagridEditEnabled(), + hasMenu: isDatagridEnabled(), trailingRowOptions: { targetColumn: --index }, }; }), diff --git a/public/app/plugins/panel/datagrid/utils.test.ts b/public/app/plugins/panel/datagrid/utils.test.ts index 3d493c2106a..79144a21426 100644 --- a/public/app/plugins/panel/datagrid/utils.test.ts +++ b/public/app/plugins/panel/datagrid/utils.test.ts @@ -54,7 +54,7 @@ describe('when deleting rows', () => { expect(newDf.fields[2].values.toArray()).toEqual(['a', 'c', 'e']); expect(newDf.length).toEqual(3); - newDf = deleteRows(df, [2], true); + newDf = deleteRows(newDf, [2], true); expect(newDf.fields[0].values.toArray()).toEqual(['a', 'c']); expect(newDf.fields[1].values.toArray()).toEqual([1, 3]); diff --git a/public/app/plugins/panel/datagrid/utils.ts b/public/app/plugins/panel/datagrid/utils.ts index 5cb6cf08724..3545dc3af86 100644 --- a/public/app/plugins/panel/datagrid/utils.ts +++ b/public/app/plugins/panel/datagrid/utils.ts @@ -1,11 +1,9 @@ import { css } from '@emotion/css'; import { CompactSelection, GridCell, GridCellKind, GridSelection, Theme } from '@glideapps/glide-data-grid'; -import { ArrayVector, DataFrame, DataFrameJSON, dataFrameToJSON, Field, GrafanaTheme2, FieldType } from '@grafana/data'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; +import { DataFrame, Field, GrafanaTheme2, FieldType } from '@grafana/data'; -import { isDatagridEditEnabled } from './featureFlagUtils'; +import { isDatagridEnabled } from './featureFlagUtils'; const HEADER_FONT_FAMILY = '600 13px Inter'; const CELL_FONT_FAMILY = '400 13px Inter'; @@ -25,11 +23,6 @@ export const EMPTY_DF = { length: 0, }; -export const GRAFANA_DS = { - type: 'grafana', - uid: 'grafana', -}; - export const EMPTY_CELL: GridCell = { kind: GridCellKind.Text, data: '', @@ -79,6 +72,17 @@ interface CellRange { height: number; } +export async function updateSnapshot( + frame: DataFrame, + updateData?: (frames: DataFrame[]) => Promise +): Promise { + if (updateData && isDatagridEnabled()) { + return await updateData([frame]); + } + + return false; +} + export const getTextWidth = (text: string, isHeader = false): number => { const context = TEXT_CANVAS.getContext('2d'); context!.font = isHeader ? HEADER_FONT_FAMILY : CELL_FONT_FAMILY; @@ -107,7 +111,12 @@ export const getCellWidth = (field: Field): number => { }; export const deleteRows = (gridData: DataFrame, rows: number[], hardDelete = false): DataFrame => { - for (const field of gridData.fields) { + const copy = { + ...gridData, + fields: gridData.fields.map((field) => ({ ...field, values: field.values.slice() })), + }; + + for (const field of copy.fields) { const valuesArray = field.values.toArray(); //delete from the end of the array to avoid index shifting @@ -119,13 +128,12 @@ export const deleteRows = (gridData: DataFrame, rows: number[], hardDelete = fal } } - field.values = new ArrayVector(valuesArray); + field.values = [...valuesArray]; } return { - ...gridData, - fields: [...gridData.fields], - length: gridData.fields[0]?.values.length ?? 0, + ...copy, + length: copy.fields[0]?.values.length ?? 0, }; }; @@ -133,50 +141,29 @@ export const clearCellsFromRangeSelection = (gridData: DataFrame, range: CellRan const colFrom: number = range.x; const rowFrom: number = range.y; const colTo: number = range.x + range.width - 1; + const copy = { + ...gridData, + fields: gridData.fields.map((field) => ({ ...field, values: field.values.slice() })), + }; for (let i = colFrom; i <= colTo; i++) { - const field = gridData.fields[i]; + const field = copy.fields[i]; const valuesArray = field.values.toArray(); valuesArray.splice(rowFrom, range.height, ...new Array(range.height).fill(null)); - field.values = new ArrayVector(valuesArray); + field.values = [...valuesArray]; } return { - ...gridData, - fields: [...gridData.fields], - length: gridData.fields[0]?.values.length ?? 0, + ...copy, + length: copy.fields[0]?.values.length ?? 0, }; }; -export const publishSnapshot = (data: DataFrame, panelID: number): void => { - if (!isDatagridEditEnabled()) { - return; - } - - const snapshot: DataFrameJSON[] = [dataFrameToJSON(data)]; - const dashboard = getDashboardSrv().getCurrent(); - const panelModel = dashboard?.getPanelById(panelID); - - const query: GrafanaQuery = { - refId: 'A', - queryType: GrafanaQueryType.Snapshot, - snapshot, - datasource: GRAFANA_DS, - }; - - panelModel!.updateQueries({ - dataSource: GRAFANA_DS, - queries: [query], - }); - - panelModel!.refresh(); -}; - //Converting an array of nulls or undefineds returns them as strings and prints them in the cells instead of empty cells. Thus the cleanup func export const cleanStringFieldAfterConversion = (field: Field): void => { const valuesArray = field.values.toArray(); - field.values = new ArrayVector(valuesArray.map((val) => (val === 'undefined' || val === 'null' ? null : val))); + field.values = valuesArray.map((val) => (val === 'undefined' || val === 'null' ? null : val)); return; }; @@ -217,7 +204,7 @@ export const getGridCellKind = (field: Field, row: number, hasGridSelection = fa return { kind: GridCellKind.Number, data: value ? value : 0, - allowOverlay: isDatagridEditEnabled()! && !hasGridSelection, + allowOverlay: isDatagridEnabled()! && !hasGridSelection, readonly: false, displayData: value !== null && value !== undefined ? value.toString() : '', }; @@ -225,7 +212,7 @@ export const getGridCellKind = (field: Field, row: number, hasGridSelection = fa return { kind: GridCellKind.Text, data: value ? value : '', - allowOverlay: isDatagridEditEnabled()! && !hasGridSelection, + allowOverlay: isDatagridEnabled()! && !hasGridSelection, readonly: false, displayData: value !== null && value !== undefined ? value.toString() : '', }; @@ -233,7 +220,7 @@ export const getGridCellKind = (field: Field, row: number, hasGridSelection = fa return { kind: GridCellKind.Text, data: value ? value : '', - allowOverlay: isDatagridEditEnabled()! && !hasGridSelection, + allowOverlay: isDatagridEnabled()! && !hasGridSelection, readonly: false, displayData: value !== null && value !== undefined ? value.toString() : '', }; @@ -301,9 +288,16 @@ export const getStyles = (theme: GrafanaTheme2, isResizeInProgress: boolean) => }; export const hasGridSelection = (gridSelection: GridSelection): boolean => { - if (!gridSelection.current) { + if (gridSelection.rows.length || gridSelection.columns.length) { + return true; + } + + if (gridSelection.current === undefined) { return false; } - return gridSelection.current.range && gridSelection.current.range.height > 1 && gridSelection.current.range.width > 1; + return ( + gridSelection.current.range && + !(gridSelection.current.range.height === 1 && gridSelection.current.range.width === 1) + ); }; From e899d2bc7e8060ab12dbf1763e6aca401adf31aa Mon Sep 17 00:00:00 2001 From: Armand Grillet <2117580+armandgrillet@users.noreply.github.com> Date: Wed, 26 Apr 2023 15:00:34 +0200 Subject: [PATCH 427/729] Fix issue-labeled.yml GH workflow (#67283) --- .github/teams.yml | 1 - .github/workflows/issue-labeled.yml | 16 ---------------- 2 files changed, 17 deletions(-) diff --git a/.github/teams.yml b/.github/teams.yml index 00259652bee..38b8e264661 100644 --- a/.github/teams.yml +++ b/.github/teams.yml @@ -7,5 +7,4 @@ test: # Alerting team area/alerting: - github-board: 52 channel-label: C02B9MXQE0J diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index ac8c19754ae..dada3acf164 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -14,18 +14,12 @@ jobs: - name: "Determine which team to notify" run: | # Default to null values. - BOARD="null" CHANNEL="null" echo "${{ github.event.label.name }} label added" export CURRENT_LABEL="${{ github.event.label.name }}" # Enable the use of the label in yq evaluations # yq is installed by default in ubuntu-latest if [[ $(yq e 'keys | .[] | select(. == env(CURRENT_LABEL))' teams.yml ) ]]; then - # Check if we have a board set to use. - if [[ $(yq '.[env(CURRENT_LABEL)] | has("github-board")' teams.yml ) == true ]]; then - BOARD=$(yq '.[env(CURRENT_LABEL)].github-board' teams.yml) - echo "Ready to add issue to Grafana board ${BOARD}" - fi # Check if we have a channel set to notify on comments. if [[ $(yq '.[env(CURRENT_LABEL)] | has("channel-label")' teams.yml ) == true ]]; then CHANNEL=$(yq '.[env(CURRENT_LABEL)].channel-label' teams.yml) @@ -34,18 +28,8 @@ jobs: fi # set environment for next step - echo "BOARD=${BOARD}" >> $GITHUB_ENV echo "CHANNEL=${CHANNEL}" >> $GITHUB_ENV - - name: "Add to GitHub board" - if: ${{ env.BOARD != 'null' }} - uses: leonsteinhaeuser/project-beta-automations@v2.1.0 - with: - project_id: ${{ env.BOARD }} - organization: grafana - resource_node_id: ${{ github.event.issue.node_id }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - - name: "Prepare payload" uses: frabert/replace-string-action@v2.0 id: preparePayload From 22713186cb776b23c724ee55f445053131c2e90d Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Wed, 26 Apr 2023 15:01:32 +0200 Subject: [PATCH 428/729] Toolkit: Remove `plugin:ci-build` `plugin:ci-package` `plugin:ci-report` and related files (#67212) --- .betterer.results | 28 -- packages/grafana-toolkit/package.json | 1 - packages/grafana-toolkit/src/cli/index.ts | 62 ----- .../src/cli/tasks/plugin.ci.ts | 258 ------------------ .../src/cli/tasks/plugin.update.ts | 24 -- .../src/cli/tasks/plugin/bundle.managed.ts | 34 --- packages/grafana-toolkit/src/plugins/env.ts | 137 ---------- packages/grafana-toolkit/src/plugins/index.ts | 3 - .../grafana-toolkit/src/plugins/manifest.ts | 91 ------ packages/grafana-toolkit/src/plugins/types.ts | 104 ------- packages/grafana-toolkit/src/plugins/utils.ts | 132 --------- .../grafana-toolkit/src/plugins/workflow.ts | 100 ------- yarn.lock | 10 - 13 files changed, 984 deletions(-) delete mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts delete mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.update.ts delete mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts delete mode 100644 packages/grafana-toolkit/src/plugins/manifest.ts delete mode 100644 packages/grafana-toolkit/src/plugins/types.ts delete mode 100644 packages/grafana-toolkit/src/plugins/utils.ts delete mode 100644 packages/grafana-toolkit/src/plugins/workflow.ts diff --git a/.betterer.results b/.betterer.results index ae38d94c5e2..449693d7e46 100644 --- a/.betterer.results +++ b/.betterer.results @@ -885,17 +885,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "packages/grafana-toolkit/src/cli/tasks/plugin.utils.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"] ], - "packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-toolkit/src/cli/tasks/task.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -925,28 +919,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "packages/grafana-toolkit/src/plugins/manifest.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, "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.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"] - ], - "packages/grafana-toolkit/src/plugins/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/grafana-toolkit/src/plugins/utils.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "packages/grafana-toolkit/src/plugins/workflow.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "packages/grafana-ui/src/components/Card/Card.tsx: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-toolkit/package.json b/packages/grafana-toolkit/package.json index c4ab0b6a77d..6d36ce4b905 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -94,7 +94,6 @@ "less": "^4.1.2", "less-loader": "^10.2.0", "lodash": "^4.17.21", - "md5-file": "^5.0.0", "mini-css-extract-plugin": "^2.6.0", "ora": "^5.4.1", "postcss": "^8.4.12", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index dbf7d8e7ab0..0507f026aba 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -4,10 +4,7 @@ import { program } from 'commander'; import { nodeVersionCheckerTask } from './tasks/nodeVersionChecker'; import { buildPackageTask } from './tasks/package.build'; import { pluginBuildTask } from './tasks/plugin.build'; -import { ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci'; -import { pluginUpdateTask } from './tasks/plugin.update'; import { getToolkitVersion, githubPublishTask } from './tasks/plugin.utils'; -import { bundleManagedTask } from './tasks/plugin/bundle.managed'; import { templateTask } from './tasks/template'; import { toolkitBuildTask } from './tasks/toolkit.build'; import { execTask } from './utils/execTask'; @@ -130,53 +127,6 @@ export const run = (includeInternalScripts = false) => { process.exit(1); }); - program - .command('plugin:ci-build') - .option('--finish', 'move all results to the jobs folder', false) - .option('--maxJestWorkers |', 'Limit number of Jest workers spawned') - .description('[deprecated] Build the plugin, leaving results in /dist and /coverage') - .action(async (cmd) => { - await execTask(ciBuildPluginTask)({ - finish: cmd.finish, - maxJestWorkers: cmd.maxJestWorkers, - }); - }); - - program - .command('plugin:ci-package') - .option('--signatureType ', 'Signature Type') - .option('--rootUrls ', 'Root URLs') - .option('--signing-admin', 'Use the admin API endpoint for signing the manifest. (deprecated)', false) - .description('[deprecated] Create a zip packages for the plugin') - .action(async (cmd) => { - await execTask(ciPackagePluginTask)({ - signatureType: cmd.signatureType, - rootUrls: cmd.rootUrls, - }); - }); - - program - .command('plugin:ci-report') - .description('[deprecated] Build a report for this whole process') - .option('--upload', 'upload packages also') - .action(async (cmd) => { - await execTask(ciPluginReportTask)({ - upload: cmd.upload, - }); - }); - - program - .command('plugin:bundle-managed') - .description('[Deprecated] Builds managed plugins') - .action(async (cmd) => { - console.log( - chalk.yellow.bold( - `⚠️ This command is deprecated and will be removed in v10. No further support will be provided. ⚠️` - ) - ); - await execTask(bundleManagedTask)({}); - }); - program .command('plugin:github-publish') .option('--dryrun', 'Do a dry run only', false) @@ -197,18 +147,6 @@ export const run = (includeInternalScripts = false) => { }); }); - program - .command('plugin:update-circleci') - .description('[Deprecated] Update plugin') - .action(async (cmd) => { - console.log( - chalk.yellow.bold( - `⚠️ This command is deprecated and will be removed in v10. No further support will be provided. ⚠️` - ) - ); - await execTask(pluginUpdateTask)({}); - }); - program.on('command:*', () => { console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' ')); process.exit(1); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts deleted file mode 100644 index 53078e4185e..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ /dev/null @@ -1,258 +0,0 @@ -import execa = require('execa'); -import fs from 'fs-extra'; -import path = require('path'); -import rimrafCallback from 'rimraf'; -import { promisify } from 'util'; - -import { getPluginId } from '../../config/utils/getPluginId'; -import { assertRootUrlIsValid, getPluginJson } from '../../config/utils/pluginValidation'; -import { - getJobFolder, - writeJobStats, - getCiFolder, - getPluginBuildInfo, - getPullRequestNumber, - getCircleDownloadBaseURL, -} from '../../plugins/env'; -import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest'; -import { PluginPackageDetails, PluginBuildReport } from '../../plugins/types'; -import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils'; -import { agregateWorkflowInfo, agregateCoverageInfo, agregateTestInfo } from '../../plugins/workflow'; - -import { pluginBuildRunner } from './plugin.build'; -import { Task, TaskRunner } from './task'; -const rimraf = promisify(rimrafCallback); - -export interface PluginCIOptions { - finish?: boolean; - upload?: boolean; - signatureType?: string; - rootUrls?: string[]; - maxJestWorkers?: string; -} - -/** - * 1. BUILD - * - * when platform exists it is building backend, otherwise frontend - * - * Each build writes data: - * ~/ci/jobs/build_xxx/ - * - * Anything that should be put into the final zip file should be put in: - * ~/ci/jobs/build_xxx/dist - * - * @deprecated -- this task was written with a specific circle-ci build in mind. That system - * has been replaced with Drone, and this is no longer the best practice. Any new work - * should be defined in the grafana build pipeline tool or drone configs directly. - */ -const buildPluginRunner: TaskRunner = async ({ finish, maxJestWorkers }) => { - const start = Date.now(); - - if (finish) { - const workDir = getJobFolder(); - await rimraf(workDir); - fs.mkdirSync(workDir); - - // Move local folders to the scoped job folder - for (const name of ['dist', 'coverage']) { - const dir = path.resolve(process.cwd(), name); - if (fs.existsSync(dir)) { - fs.moveSync(dir, path.resolve(workDir, name)); - } - } - writeJobStats(start, workDir); - } else { - // Do regular build process with coverage - await pluginBuildRunner({ coverage: true, maxJestWorkers }); - } -}; - -export const ciBuildPluginTask = new Task('Build Plugin', buildPluginRunner); - -/** - * 2. Package - * - * Take everything from `~/ci/job/{any}/dist` and - * 1. merge it into: `~/ci/dist` - * 2. zip it into packages in `~/ci/packages` - * 3. prepare grafana environment in: `~/ci/grafana-test-env` - * - * - * @deprecated -- this task was written with a specific circle-ci build in mind. That system - * has been replaced with Drone, and this is no longer the best practice. Any new work - * should be defined in the grafana build pipeline tool or drone configs directly. - */ -const packagePluginRunner: TaskRunner = async ({ signatureType, rootUrls }) => { - const start = Date.now(); - const ciDir = getCiFolder(); - const packagesDir = path.resolve(ciDir, 'packages'); - const distDir = path.resolve(ciDir, 'dist'); - const docsDir = path.resolve(ciDir, 'docs'); - const jobsDir = path.resolve(ciDir, 'jobs'); - - fs.exists(jobsDir, (jobsDirExists) => { - if (!jobsDirExists) { - throw new Error('You must run plugin:ci-build prior to running plugin:ci-package'); - } - }); - - const grafanaEnvDir = path.resolve(ciDir, 'grafana-test-env'); - await execa('rimraf', [packagesDir, distDir, grafanaEnvDir]); - fs.mkdirSync(packagesDir); - fs.mkdirSync(distDir); - - // Updating the dist dir to have a pluginId named directory in it - // The zip needs to contain the plugin code wrapped in directory with a pluginId name - const distContentDir = path.resolve(distDir, getPluginId()); - fs.mkdirSync(grafanaEnvDir); - - console.log('Build Dist Folder'); - - // 1. Check for a local 'dist' folder - const d = path.resolve(process.cwd(), 'dist'); - if (fs.existsSync(d)) { - await execa('cp', ['-rn', d + '/.', distContentDir]); - } - - // 2. Look for any 'dist' folders under ci/job/XXX/dist - const dirs = fs.readdirSync(path.resolve(ciDir, 'jobs')); - for (const j of dirs) { - const contents = path.resolve(ciDir, 'jobs', j, 'dist'); - if (fs.existsSync(contents)) { - try { - await execa('cp', ['-rn', contents + '/.', distContentDir]); - } catch (er) { - throw new Error('Duplicate files found in dist folders'); - } - } - } - - console.log('Save the source info in plugin.json'); - const pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); - const pluginInfo = getPluginJson(pluginJsonFile); - pluginInfo.info.build = await getPluginBuildInfo(); - fs.writeFileSync(pluginJsonFile, JSON.stringify(pluginInfo, null, 2), { encoding: 'utf-8' }); - - // Write a MANIFEST.txt file in the dist folder - try { - const manifest = await buildManifest(distContentDir); - if (signatureType) { - manifest.signatureType = signatureType; - } - if (rootUrls && rootUrls.length > 0) { - rootUrls.forEach(assertRootUrlIsValid); - manifest.rootUrls = rootUrls; - } - const signedManifest = await signManifest(manifest); - await saveManifest(distContentDir, signedManifest); - } catch (err) { - console.warn(`Error signing manifest: ${distContentDir}`, err); - } - - console.log('Building ZIP'); - let zipName = pluginInfo.id + '-' + pluginInfo.info.version + '.zip'; - let zipFile = path.resolve(packagesDir, zipName); - await execa('zip', ['-r', zipFile, '.'], { cwd: distDir }); - - const zipStats = fs.statSync(zipFile); - if (zipStats.size < 100) { - throw new Error('Invalid zip file: ' + zipFile); - } - - // Make a copy so it is easy for report to read - await execa('cp', [pluginJsonFile, distDir]); - - const info: PluginPackageDetails = { - plugin: await getPackageDetails(zipFile, distDir), - }; - - console.log('Setup Grafana Environment'); - let p = path.resolve(grafanaEnvDir, 'plugins', pluginInfo.id); - fs.mkdirSync(p, { recursive: true }); - await execa('unzip', [zipFile, '-d', p]); - - // If docs exist, zip them into packages - if (fs.existsSync(docsDir)) { - console.log('Creating documentation zip'); - zipName = pluginInfo.id + '-' + pluginInfo.info.version + '-docs.zip'; - zipFile = path.resolve(packagesDir, zipName); - await execa('zip', ['-r', zipFile, '.'], { cwd: docsDir }); - - info.docs = await getPackageDetails(zipFile, docsDir); - } - - p = path.resolve(packagesDir, 'info.json'); - fs.writeFileSync(p, JSON.stringify(info, null, 2), { encoding: 'utf-8' }); - - // Write the custom settings - p = path.resolve(grafanaEnvDir, 'custom.ini'); - const customIniBody = - `# Autogenerated by @grafana/toolkit \n` + - `[paths] \n` + - `plugins = ${path.resolve(grafanaEnvDir, 'plugins')}\n` + - `\n`; // empty line - fs.writeFileSync(p, customIniBody, { encoding: 'utf-8' }); - - writeJobStats(start, getJobFolder()); -}; - -export const ciPackagePluginTask = new Task('Bundle Plugin', packagePluginRunner); - -/** - * 4. Report - * - * Create a report from all the previous steps - * - * @deprecated -- this task was written with a specific circle-ci build in mind. That system - * has been replaced with Drone, and this is no longer the best practice. Any new work - * should be defined in the grafana build pipeline tool or drone configs directly. - */ -const pluginReportRunner: TaskRunner = async ({ upload }) => { - const ciDir = path.resolve(process.cwd(), 'ci'); - const packageDir = path.resolve(ciDir, 'packages'); - const packageInfo = require(path.resolve(packageDir, 'info.json')) as PluginPackageDetails; - - const pluginJsonFile = path.resolve(ciDir, 'dist', 'plugin.json'); - console.log('Load info from: ' + pluginJsonFile); - - const pluginMeta = getPluginJson(pluginJsonFile); - const report: PluginBuildReport = { - plugin: pluginMeta, - packages: packageInfo, - workflow: agregateWorkflowInfo(), - coverage: agregateCoverageInfo(), - tests: agregateTestInfo(), - artifactsBaseURL: await getCircleDownloadBaseURL(), - grafanaVersion: getGrafanaVersions(), - git: await readGitLog(), - }; - const pr = getPullRequestNumber(); - if (pr) { - report.pullRequest = pr; - } - - // Save the report to disk - const file = path.resolve(ciDir, 'report.json'); - fs.writeFileSync(file, JSON.stringify(report, null, 2), { encoding: 'utf-8' }); - - const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY; - if (!GRAFANA_API_KEY) { - console.log('Enter a GRAFANA_API_KEY to upload the plugin report'); - return; - } - const url = `https://grafana.com/api/plugins/${report.plugin.id}/ci`; - - console.log('Sending report to:', url); - const axios = require('axios'); - const info = await axios.post(url, report, { - headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY }, - }); - if (info.status === 200) { - console.log('OK: ', info.data); - } else { - console.warn('Error: ', info); - } -}; - -export const ciPluginReportTask = new Task('Generate Plugin Report', pluginReportRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts deleted file mode 100644 index fee3286b395..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts +++ /dev/null @@ -1,24 +0,0 @@ -import fs = require('fs'); -import path = require('path'); - -import { useSpinner } from '../utils/useSpinner'; - -import { Task, TaskRunner } from './task'; - -interface UpdatePluginTask {} - -const updateCiConfig = () => - useSpinner('Updating CircleCI config', async () => { - const ciConfigPath = path.join(process.cwd(), '.circleci'); - if (!fs.existsSync(ciConfigPath)) { - fs.mkdirSync(ciConfigPath); - } - - const sourceFile = require.resolve('@grafana/toolkit/config/circleci/config.yml'); - const destFile = path.join(ciConfigPath, 'config.yml'); - fs.copyFileSync(sourceFile, destFile); - }); - -const pluginUpdateRunner: TaskRunner = () => updateCiConfig(); - -export const pluginUpdateTask = new Task('Update Plugin', pluginUpdateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts deleted file mode 100644 index 7ffd6c32d6d..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts +++ /dev/null @@ -1,34 +0,0 @@ -import execa = require('execa'); -import { promises as fs } from 'fs'; - -import { Task, TaskRunner } from '../task'; - -interface BundeManagedOptions {} - -const MANAGED_PLUGINS_PATH = `${process.cwd()}/plugins-bundled`; -const MANAGED_PLUGINS_SCOPES = ['internal', 'external']; - -const bundleManagedPluginsRunner: TaskRunner = async () => { - await Promise.all( - MANAGED_PLUGINS_SCOPES.map(async (scope) => { - try { - const plugins = await fs.readdir(`${MANAGED_PLUGINS_PATH}/${scope}`); - if (plugins.length > 0) { - for (const plugin of plugins) { - try { - console.log(`[${scope}]: ${plugin} building...`); - await execa('yarn', ['build'], { cwd: `${MANAGED_PLUGINS_PATH}/${scope}/${plugin}` }); - console.log(`[${scope}]: ${plugin} bundled`); - } catch (e: any) { - console.log(e.stdout); - } - } - } - } catch (e) { - console.log(e); - } - }) - ); -}; - -export const bundleManagedTask = new Task('Bundle managed plugins', bundleManagedPluginsRunner); diff --git a/packages/grafana-toolkit/src/plugins/env.ts b/packages/grafana-toolkit/src/plugins/env.ts index f79b68d449d..56b9e126351 100644 --- a/packages/grafana-toolkit/src/plugins/env.ts +++ b/packages/grafana-toolkit/src/plugins/env.ts @@ -1,106 +1,6 @@ -import execa from 'execa'; import fs from 'fs'; import path from 'path'; -import { PluginBuildInfo } from '@grafana/data'; - -import { JobInfo } from './types'; - -const getJobFromProcessArgv = () => { - const arg = process.argv[2]; - if (arg && arg.startsWith('plugin:ci-')) { - const task = arg.substring('plugin:ci-'.length); - if ('build' === task) { - if ('--backend' === process.argv[3] && process.argv[4]) { - return task + '_' + process.argv[4]; - } - return 'build_plugin'; - } - return task; - } - return 'unknown_job'; -}; - -export const job = - (process.env.DRONE_STEP_NAME ? process.env.DRONE_STEP_NAME : process.env.CIRCLE_JOB) || getJobFromProcessArgv(); - -export const getPluginBuildInfo = async (): Promise => { - if (process.env.CI === 'true') { - let repo: string | undefined; - let branch: string | undefined; - let hash: string | undefined; - let build: number | undefined; - let pr: number | undefined; - if (process.env.DRONE === 'true') { - repo = process.env.DRONE_REPO_LINK; - branch = process.env.DRONE_BRANCH; - hash = process.env.DRONE_COMMIT_SHA; - build = parseInt(process.env.DRONE_BUILD_NUMBER || '', 10); - pr = parseInt(process.env.DRONE_PULL_REQUEST || '', 10); - } else if (process.env.CIRCLECI === 'true') { - repo = process.env.CIRCLE_REPOSITORY_URL; - branch = process.env.CIRCLE_BRANCH; - hash = process.env.CIRCLE_SHA1; - build = parseInt(process.env.CIRCLE_BUILD_NUM || '', 10); - const url = process.env.CIRCLE_PULL_REQUEST || ''; - const idx = url.lastIndexOf('/') + 1; - pr = parseInt(url.substring(idx), 10); - } - - const info: PluginBuildInfo = { - time: Date.now(), - repo, - branch, - hash, - }; - if (pr) { - info.pr = pr; - } - if (build) { - info.number = build; - } - return info; - } - - const branch = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']); - const hash = await execa('git', ['rev-parse', 'HEAD']); - return { - time: Date.now(), - branch: branch.stdout, - hash: hash.stdout, - }; -}; - -export const getBuildNumber = (): number | undefined => { - if (process.env.DRONE === 'true') { - return parseInt(process.env.DRONE_BUILD_NUMBER || '', 10); - } else if (process.env.CIRCLECI === 'true') { - return parseInt(process.env.CIRCLE_BUILD_NUM || '', 10); - } - - return undefined; -}; - -export const getPullRequestNumber = (): number | undefined => { - if (process.env.DRONE === 'true') { - return parseInt(process.env.DRONE_PULL_REQUEST || '', 10); - } else if (process.env.CIRCLECI === 'true') { - const url = process.env.CIRCLE_PULL_REQUEST || ''; - const idx = url.lastIndexOf('/') + 1; - return parseInt(url.substring(idx), 10); - } - - return undefined; -}; - -export const getJobFolder = () => { - const dir = path.resolve(process.cwd(), 'ci', 'jobs', job); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - return dir; -}; - export const getCiFolder = () => { const dir = path.resolve(process.cwd(), 'ci'); if (!fs.existsSync(dir)) { @@ -108,40 +8,3 @@ export const getCiFolder = () => { } return dir; }; - -export const writeJobStats = (startTime: number, workDir: string) => { - const endTime = Date.now(); - const stats: JobInfo = { - job, - startTime, - endTime, - elapsed: endTime - startTime, - buildNumber: getBuildNumber(), - }; - const f = path.resolve(workDir, 'job.json'); - fs.writeFile(f, JSON.stringify(stats, null, 2), (err) => { - if (err) { - throw new Error('Unable to stats: ' + f); - } - }); -}; - -// https://circleci.com/api/v1.1/project/github/NatelEnergy/grafana-discrete-panel/latest/artifacts -export async function getCircleDownloadBaseURL(): Promise { - try { - const axios = require('axios'); - const repo = process.env.CIRCLE_PROJECT_REPONAME; - const user = process.env.CIRCLE_PROJECT_USERNAME; - let url = `https://circleci.com/api/v1.1/project/github/${user}/${repo}/latest/artifacts`; - const rsp = await axios.get(url); - for (const s of rsp.data) { - const { path, url } = s; - if (url && path && path.endsWith('report.json')) { - return url.substring(url.length - 'report.json'.length); - } - } - } catch (e) { - console.log('Error reading CircleCI artifact URL', e); - } - return undefined; -} diff --git a/packages/grafana-toolkit/src/plugins/index.ts b/packages/grafana-toolkit/src/plugins/index.ts index c302e909020..c1532d6d2e4 100644 --- a/packages/grafana-toolkit/src/plugins/index.ts +++ b/packages/grafana-toolkit/src/plugins/index.ts @@ -1,4 +1 @@ export * from './env'; -export * from './utils'; -export * from './workflow'; -export * from './types'; diff --git a/packages/grafana-toolkit/src/plugins/manifest.ts b/packages/grafana-toolkit/src/plugins/manifest.ts deleted file mode 100644 index 17c1f052170..00000000000 --- a/packages/grafana-toolkit/src/plugins/manifest.ts +++ /dev/null @@ -1,91 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; - -import { ManifestInfo } from './types'; - -const MANIFEST_FILE = 'MANIFEST.txt'; - -async function* walk(dir: string, baseDir: string): AsyncGenerator { - for await (const d of await (fs.promises as any).opendir(dir)) { - const entry = path.posix.join(dir, d.name); - if (d.isDirectory()) { - yield* await walk(entry, baseDir); - } else if (d.isFile()) { - yield path.posix.relative(baseDir, entry); - } else if (d.isSymbolicLink()) { - const realPath = await (fs.promises as any).realpath(entry); - if (!realPath.startsWith(baseDir)) { - throw new Error( - `symbolic link ${path.posix.relative( - baseDir, - entry - )} targets a file outside of the base directory: ${baseDir}` - ); - } - // if resolved symlink target is a file include it in the manifest - const stats = await (fs.promises as any).stat(realPath); - if (stats.isFile()) { - yield path.posix.relative(baseDir, entry); - } - } - } -} - -export async function buildManifest(dir: string): Promise { - const pluginJson = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), { encoding: 'utf8' })); - - const manifest = { - plugin: pluginJson.id, - version: pluginJson.info.version, - files: {}, - } as ManifestInfo; - - for await (const p of await walk(dir, dir)) { - if (p === MANIFEST_FILE) { - continue; - } - - manifest.files[p] = crypto - .createHash('sha256') - .update(fs.readFileSync(path.join(dir, p))) - .digest('hex'); - } - - return manifest; -} - -export async function signManifest(manifest: ManifestInfo): Promise { - const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY; - if (!GRAFANA_API_KEY) { - throw new Error('You must enter a GRAFANA_API_KEY to sign the plugin manifest'); - } - - const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api'; - const url = GRAFANA_COM_URL + '/plugins/ci/sign'; - - const axios = require('axios'); - - try { - const info = await axios.post(url, manifest, { - headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY }, - }); - if (info.status !== 200) { - console.warn('Error: ', info); - throw new Error('Error signing manifest'); - } - - return info.data; - } catch (err: any) { - if (err.response?.data?.message) { - throw new Error('Error signing manifest: ' + err.response.data.message); - } - - throw new Error('Error signing manifest: ' + err.message); - } -} - -export async function saveManifest(dir: string, signedManifest: string): Promise { - fs.writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest); - return true; -} diff --git a/packages/grafana-toolkit/src/plugins/types.ts b/packages/grafana-toolkit/src/plugins/types.ts deleted file mode 100644 index 296cbc8252a..00000000000 --- a/packages/grafana-toolkit/src/plugins/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { PluginMeta, KeyValue } from '@grafana/data'; - -export interface PluginPackageDetails { - plugin: ZipFileInfo; - docs?: ZipFileInfo; -} - -export interface PluginBuildReport { - plugin: PluginMeta; - packages: PluginPackageDetails; - workflow: WorkflowInfo; - coverage: CoverageInfo[]; - tests: TestResultsInfo[]; - git?: GitLogInfo; - pullRequest?: number; - artifactsBaseURL?: string; - grafanaVersion?: KeyValue; -} - -export interface JobInfo { - job?: string; - startTime: number; - endTime: number; - elapsed: number; - status?: string; - buildNumber?: number; -} - -export interface WorkflowInfo extends JobInfo { - workflowId?: string; - jobs: JobInfo[]; - user?: string; - repo?: string; -} - -export interface CoverageDetails { - total: number; - covered: number; - skipped: number; - pct: number; -} - -export interface CoverageInfo { - job: string; - summary: { [key: string]: CoverageDetails }; - report?: string; // path to report -} - -export interface TestResultsInfo { - job: string; - grafana?: any; - error?: string; - passed: number; - failed: number; - screenshots: string[]; -} - -export interface CountAndSize { - count: number; - bytes: number; -} - -export interface ExtensionSize { - [key: string]: CountAndSize; -} - -export interface ZipFileInfo { - name: string; - size: number; - contents: ExtensionSize; - sha1?: string; - md5?: string; -} - -interface UserInfo { - name: string; - email: string; - time?: number; -} - -export interface GitLogInfo { - commit: string; - tree: string; - subject: string; - body?: string; - notes?: string; - author: UserInfo; - commiter: UserInfo; -} - -export interface ManifestInfo { - // time: number; << filled in by the server - // keyId: string; << filled in by the server - // signedByOrg: string; << filled in by the server - // signedByOrgName: string; << filled in by the server - signatureType?: string; // filled in by the server if not specified - rootUrls?: string[]; // for private signatures - plugin: string; - version: string; - files: Record; - toolkit?: { - version: string; - }; -} diff --git a/packages/grafana-toolkit/src/plugins/utils.ts b/packages/grafana-toolkit/src/plugins/utils.ts deleted file mode 100644 index e968fc31fd0..00000000000 --- a/packages/grafana-toolkit/src/plugins/utils.ts +++ /dev/null @@ -1,132 +0,0 @@ -import execa from 'execa'; -import fs from 'fs'; -import path from 'path'; - -import { KeyValue } from '@grafana/data'; - -import { ExtensionSize, ZipFileInfo, GitLogInfo } from './types'; - -const md5File = require('md5-file'); - -export function getGrafanaVersions(): KeyValue { - const dir = path.resolve(process.cwd(), 'node_modules', '@grafana'); - const versions: KeyValue = {}; - try { - fs.readdirSync(dir).forEach((file) => { - const json = require(path.resolve(dir, file, 'package.json')); - versions[file] = json.version; - }); - } catch (err) { - console.warn('Error reading toolkit versions', err); - } - return versions; -} - -export function getFileSizeReportInFolder(dir: string, info?: ExtensionSize): ExtensionSize { - const acc: ExtensionSize = info ? info : {}; - - const files = fs.readdirSync(dir); - if (files) { - files.forEach((file) => { - const newbase = path.join(dir, file); - const stat = fs.statSync(newbase); - if (stat.isDirectory()) { - getFileSizeReportInFolder(newbase, info); - } else { - let ext = '_none_'; - const idx = file.lastIndexOf('.'); - if (idx > 0) { - ext = file.substring(idx + 1).toLowerCase(); - } - const current = acc[ext]; - if (current) { - current.count += 1; - current.bytes += stat.size; - } else { - acc[ext] = { bytes: stat.size, count: 1 }; - } - } - }); - } - return acc; -} - -export async function getPackageDetails(zipFile: string, zipSrc: string, writeChecksum = true): Promise { - const zipStats = fs.statSync(zipFile); - if (zipStats.size < 100) { - throw new Error('Invalid zip file: ' + zipFile); - } - const info: ZipFileInfo = { - name: path.basename(zipFile), - size: zipStats.size, - contents: getFileSizeReportInFolder(zipSrc), - }; - try { - const exe = await execa('shasum', [zipFile]); - const idx = exe.stdout.indexOf(' '); - const sha1 = exe.stdout.substring(0, idx); - if (writeChecksum) { - fs.writeFile(zipFile + '.sha1', sha1, (err) => {}); - } - info.sha1 = sha1; - } catch { - console.warn('Unable to read SHA1 Checksum'); - } - try { - info.md5 = md5File.sync(zipFile); - } catch { - console.warn('Unable to read MD5 Checksum'); - } - return info; -} - -export function findImagesInFolder(dir: string, prefix = '', append?: string[]): string[] { - const imgs = append || []; - - const files = fs.readdirSync(dir); - if (files) { - files.forEach((file) => { - if (file.endsWith('.png')) { - imgs.push(file); - } - }); - } - - return imgs; -} - -export async function readGitLog(): Promise { - try { - let exe = await execa('git', [ - 'log', - '-1', // last line - '--pretty=format:{%n "commit": "%H",%n "tree": "%T",%n "subject": "%s",%n "author": {%n "name": "%aN",%n "email": "%aE",%n "time":"%at" },%n "commiter": {%n "name": "%cN",%n "email": "%cE",%n "time":"%ct" }%n}', - ]); - const info = JSON.parse(exe.stdout) as GitLogInfo; - - // Read the body - exe = await execa('git', [ - 'log', - '-1', // last line - '--pretty=format:%b', // Just the body (with newlines!) - ]); - if (exe.stdout && exe.stdout.length) { - info.body = exe.stdout.trim(); - } - - // Read any commit notes - exe = await execa('git', [ - 'log', - '-1', // last line - '--pretty=format:%N', // commit notes (with newlines!) - ]); - if (exe.stdout && exe.stdout.length) { - info.notes = exe.stdout.trim(); - } - - return info; - } catch (err) { - console.warn('Error REading Git log info', err); - } - return undefined; -} diff --git a/packages/grafana-toolkit/src/plugins/workflow.ts b/packages/grafana-toolkit/src/plugins/workflow.ts deleted file mode 100644 index ea653836841..00000000000 --- a/packages/grafana-toolkit/src/plugins/workflow.ts +++ /dev/null @@ -1,100 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { getBuildNumber, getCiFolder } from './env'; -import { JobInfo, WorkflowInfo, CoverageInfo, TestResultsInfo } from './types'; - -export const agregateWorkflowInfo = (): WorkflowInfo => { - const now = Date.now(); - const workflow: WorkflowInfo = { - jobs: [], - startTime: now, - endTime: now, - workflowId: process.env.CIRCLE_WORKFLOW_ID, - repo: process.env.CIRCLE_PROJECT_REPONAME, - user: process.env.CIRCLE_PROJECT_USERNAME, - buildNumber: getBuildNumber(), - elapsed: 0, - }; - - const jobsFolder = path.resolve(getCiFolder(), 'jobs'); - if (fs.existsSync(jobsFolder)) { - const files = fs.readdirSync(jobsFolder); - if (files && files.length) { - files.forEach((file) => { - const p = path.resolve(jobsFolder, file, 'job.json'); - if (fs.existsSync(p)) { - const job = require(p) as JobInfo; - workflow.jobs.push(job); - if (job.startTime < workflow.startTime) { - workflow.startTime = job.startTime; - } - if (job.endTime > workflow.endTime) { - workflow.endTime = job.endTime; - } - } else { - console.log('Missing Job info: ', p); - } - }); - } else { - console.log('NO JOBS IN: ', jobsFolder); - } - } - - workflow.elapsed = workflow.endTime - workflow.startTime; - return workflow; -}; - -export const agregateCoverageInfo = (): CoverageInfo[] => { - const coverage: CoverageInfo[] = []; - const ciDir = getCiFolder(); - const jobsFolder = path.resolve(ciDir, 'jobs'); - if (fs.existsSync(jobsFolder)) { - const files = fs.readdirSync(jobsFolder); - if (files && files.length) { - files.forEach((file) => { - const dir = path.resolve(jobsFolder, file, 'coverage'); - if (fs.existsSync(dir)) { - const s = path.resolve(dir, 'coverage-summary.json'); - const r = path.resolve(dir, 'lcov-report', 'index.html'); - if (fs.existsSync(s)) { - const raw = require(s); - const info: CoverageInfo = { - job: file, - summary: raw.total, - }; - if (fs.existsSync(r)) { - info.report = r.substring(ciDir.length); - } - coverage.push(info); - } - } - }); - } else { - console.log('NO JOBS IN: ', jobsFolder); - } - } - return coverage; -}; - -export const agregateTestInfo = (): TestResultsInfo[] => { - const tests: TestResultsInfo[] = []; - const ciDir = getCiFolder(); - const jobsFolder = path.resolve(ciDir, 'jobs'); - if (fs.existsSync(jobsFolder)) { - const files = fs.readdirSync(jobsFolder); - if (files && files.length) { - files.forEach((file) => { - if (file.startsWith('test')) { - const summary = path.resolve(jobsFolder, file, 'results.json'); - if (fs.existsSync(summary)) { - tests.push(require(summary) as TestResultsInfo); - } - } - }); - } else { - console.log('NO Jobs IN: ', jobsFolder); - } - } - return tests; -}; diff --git a/yarn.lock b/yarn.lock index a24b50d212f..c49e4cbe802 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3436,7 +3436,6 @@ __metadata: less: ^4.1.2 less-loader: ^10.2.0 lodash: ^4.17.21 - md5-file: ^5.0.0 mini-css-extract-plugin: ^2.6.0 ora: ^5.4.1 postcss: ^8.4.12 @@ -25384,15 +25383,6 @@ __metadata: languageName: node linkType: hard -"md5-file@npm:^5.0.0": - version: 5.0.0 - resolution: "md5-file@npm:5.0.0" - bin: - md5-file: cli.js - checksum: c606a00ff58adf5428e8e2f36d86e5d3c7029f9688126faca302cd83b5e92cac183a62e1d1f05fae7c2614e80f993326fd0a8d6a3a913c41ec7ea0eefc25aa76 - languageName: node - linkType: hard - "mdast-squeeze-paragraphs@npm:^4.0.0": version: 4.0.0 resolution: "mdast-squeeze-paragraphs@npm:4.0.0" From f48ef6ea50b64b9beee887bfa8ac459c60ff0b50 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Wed, 26 Apr 2023 15:02:01 +0200 Subject: [PATCH 429/729] Chore: Upgrade github.com/docker/docker dependency (#67098) --- go.mod | 12 ++++-------- go.sum | 8 ++++---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 355a0e5944f..973665796c3 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,10 @@ go 1.19 // Also, use our fork with fixes for unimplemented methods (required for Go 1.16). replace github.com/denisenkom/go-mssqldb => github.com/grafana/go-mssqldb v0.9.2 -// Avoid using v2.0.0+incompatible Redigo used by dependencies as the latest maintained branch of Redigo is v1. -replace github.com/gomodule/redigo => github.com/gomodule/redigo v1.8.9 - // Override docker/docker to avoid: // go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires // github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000 -replace github.com/docker/docker => github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 +replace github.com/docker/docker => github.com/moby/moby v23.0.4+incompatible // contains openapi encoder fixes. remove ASAP replace cuelang.org/go => github.com/sdboyer/cue v0.5.0-beta.2.0.20230419165817-251c3ae823d8 @@ -87,7 +84,7 @@ require ( 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 - github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/errors v0.9.1 github.com/prometheus/alertmanager v0.25.0 github.com/prometheus/client_golang v1.14.0 github.com/prometheus/client_model v0.3.0 @@ -126,7 +123,7 @@ require ( gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 - xorm.io/builder v0.3.6 + xorm.io/builder v0.3.6 // indirect xorm.io/core v0.7.3 xorm.io/xorm v0.8.2 ) @@ -288,7 +285,6 @@ require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/bmatcuk/doublestar v1.1.1 // indirect github.com/buildkite/yaml v2.1.0+incompatible // indirect - github.com/containerd/containerd v1.6.8 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -371,7 +367,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect - github.com/docker/docker v20.10.21+incompatible + github.com/docker/docker v23.0.4+incompatible github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect diff --git a/go.sum b/go.sum index e9f1b8a0af6..afe2f78b780 100644 --- a/go.sum +++ b/go.sum @@ -546,8 +546,6 @@ github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09Zvgq github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s= github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c= -github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs= -github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= @@ -1788,14 +1786,15 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 h1:cvy4lBOYN3gKfKj8Lzz5Q9TfviP+L7koMHY7SvkyTKs= -github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/moby/moby v23.0.4+incompatible h1:A/pe8vi9KIKhNbzR0G3wW4ACKDsMgXILBveMqiJNa8M= +github.com/moby/moby v23.0.4+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= @@ -3251,6 +3250,7 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From a576bd4f2667073521ac44248f1922c979069848 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 26 Apr 2023 15:07:51 +0200 Subject: [PATCH 430/729] DataSourcePicker: Tweak styles (#67280) --- .../components/picker/DataSourceCard.tsx | 8 ++++++ .../components/picker/DataSourceDropdown.tsx | 27 +++++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx index 8c365909ab3..b68c08622df 100644 --- a/public/app/features/datasources/components/picker/DataSourceCard.tsx +++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx @@ -60,6 +60,14 @@ function getStyles(theme: GrafanaTheme2) { logo: css` width: 32px; height: 32px; + padding-right: ${theme.spacing(1.5)}; + display: flex; + align-items: center; + + > img { + max-height: 100%; + min-width: 32px; + } `, name: css` color: ${theme.colors.text.primary}; diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index d7665765aab..585c5665098 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -7,7 +7,7 @@ import { usePopper } from 'react-popper'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { DataSourceJsonData } from '@grafana/schema'; -import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; import { useDatasource } from '../../hooks'; @@ -43,6 +43,14 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { const popper = usePopper(markerElement, selectorElement, { placement: 'bottom-start', + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 4], + }, + }, + ], }); const onClose = useCallback(() => { @@ -154,14 +162,12 @@ const PickerContent = React.forwardRef((prop return (
- - ds.name.toLowerCase().includes(filterTerm?.toLowerCase() ?? '')} - > - + ds.name.toLowerCase().includes(filterTerm?.toLowerCase() ?? '')} + >
@@ -209,7 +215,6 @@ function getStylesPickerContent(theme: GrafanaTheme2) { display: flex; flex-direction: column; height: 412px; - box-shadow: ${theme.shadows.z3}; width: 480px; background: ${theme.colors.background.primary}; box-shadow: ${theme.shadows.z3}; @@ -219,7 +224,7 @@ function getStylesPickerContent(theme: GrafanaTheme2) { `, dataSourceList: css` flex: 1; - height: 100%; + overflow: scroll; `, footer: css` flex: 0; From 4b3aead2d053d655a3730ad855ff2141229f9cf3 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Wed, 26 Apr 2023 10:46:21 -0300 Subject: [PATCH 431/729] DataLinks: encoded URL fixed (#66418) --- packages/grafana-data/src/field/fieldOverrides.ts | 2 +- public/app/features/panel/panellinks/link_srv.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index f3b248a5000..7247638d1d6 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -421,7 +421,7 @@ export const getLinksSupplier = if (href) { href = locationUtil.assureBaseUrl(href.replace(/\n/g, '')); - href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.PercentEncode); + href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode); href = locationUtil.processUrl(href); } diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index 1d2037e8a98..538b4e4f1fb 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -305,7 +305,7 @@ export class LinkSrv implements LinkService { }; if (replaceVariables) { - info.href = replaceVariables(info.href, undefined, VariableFormatID.PercentEncode); + info.href = replaceVariables(info.href, undefined, VariableFormatID.UriEncode); info.title = replaceVariables(link.title); } From a420040c73cbca863341e755b3b83020df81200b Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 26 Apr 2023 09:52:13 -0400 Subject: [PATCH 432/729] Chore: Remove CRD generation (#67286) --- .../feature-toggles/index.md | 1 - kinds/gen.go | 3 - .../src/types/featureToggles.gen.ts | 1 - pkg/codegen/jenny_crd_reg.go | 63 ----- pkg/codegen/jenny_crd_types.go | 51 ---- pkg/codegen/jenny_crd_yaml.go | 215 ---------------- pkg/codegen/tmpl/core_crd_registry.tmpl | 66 ----- pkg/codegen/tmpl/core_crd_types.tmpl | 25 -- pkg/kinds/dashboard/crd/dashboard.crd.yml | 36 --- pkg/kinds/dashboard/crd/dashboard_crd_gen.go | 34 --- .../librarypanel/crd/librarypanel.crd.yml | 122 --------- .../librarypanel/crd/librarypanel_crd_gen.go | 34 --- pkg/kinds/playlist/crd/playlist.crd.yml | 82 ------- pkg/kinds/playlist/crd/playlist_crd_gen.go | 34 --- pkg/kinds/preferences/crd/preferences.crd.yml | 56 ----- .../preferences/crd/preferences_crd_gen.go | 34 --- .../crd/publicdashboard.crd.yml | 56 ----- .../crd/publicdashboard_crd_gen.go | 34 --- .../serviceaccount/crd/serviceaccount.crd.yml | 94 ------- .../crd/serviceaccount_crd_gen.go | 34 --- pkg/kinds/team/crd/team.crd.yml | 77 ------ pkg/kinds/team/crd/team_crd_gen.go | 34 --- pkg/registry/corecrd/registry.go | 27 -- pkg/registry/corecrd/registry_gen.go | 231 ------------------ pkg/services/featuremgmt/registry.go | 7 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - 27 files changed, 1456 deletions(-) delete mode 100644 pkg/codegen/jenny_crd_reg.go delete mode 100644 pkg/codegen/jenny_crd_types.go delete mode 100644 pkg/codegen/jenny_crd_yaml.go delete mode 100644 pkg/codegen/tmpl/core_crd_registry.tmpl delete mode 100644 pkg/codegen/tmpl/core_crd_types.tmpl delete mode 100644 pkg/kinds/dashboard/crd/dashboard.crd.yml delete mode 100644 pkg/kinds/dashboard/crd/dashboard_crd_gen.go delete mode 100644 pkg/kinds/librarypanel/crd/librarypanel.crd.yml delete mode 100644 pkg/kinds/librarypanel/crd/librarypanel_crd_gen.go delete mode 100644 pkg/kinds/playlist/crd/playlist.crd.yml delete mode 100644 pkg/kinds/playlist/crd/playlist_crd_gen.go delete mode 100644 pkg/kinds/preferences/crd/preferences.crd.yml delete mode 100644 pkg/kinds/preferences/crd/preferences_crd_gen.go delete mode 100644 pkg/kinds/publicdashboard/crd/publicdashboard.crd.yml delete mode 100644 pkg/kinds/publicdashboard/crd/publicdashboard_crd_gen.go delete mode 100644 pkg/kinds/serviceaccount/crd/serviceaccount.crd.yml delete mode 100644 pkg/kinds/serviceaccount/crd/serviceaccount_crd_gen.go delete mode 100644 pkg/kinds/team/crd/team.crd.yml delete mode 100644 pkg/kinds/team/crd/team_crd_gen.go delete mode 100644 pkg/registry/corecrd/registry.go delete mode 100644 pkg/registry/corecrd/registry_gen.go diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 1abd57a3d97..bc03c5b2b41 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -119,6 +119,5 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref | Feature toggle name | Description | | --------------------- | -------------------------------------------------------------- | -| `k8s` | Explore native k8s integrations | | `entityStore` | SQL-based entity store (requires storage flag also) | | `externalServiceAuth` | Starts an OAuth2 authentication provider for external services | diff --git a/kinds/gen.go b/kinds/gen.go index bd6bb368c95..ade2ff9fc01 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -43,9 +43,6 @@ func main() { codegen.BaseCoreRegistryJenny(filepath.Join("pkg", "registry", "corekind"), cuectx.GoCoreKindParentPath), codegen.LatestMajorsOrXJenny(cuectx.TSCoreKindParentPath, codegen.TSTypesJenny{}), codegen.TSVeneerIndexJenny(filepath.Join("packages", "grafana-schema", "src")), - codegen.CRDTypesJenny(cuectx.GoCoreKindParentPath), - codegen.YamlCRDJenny(cuectx.GoCoreKindParentPath), - codegen.CRDKindRegistryJenny(filepath.Join("pkg", "registry", "corecrd")), codegen.DocsJenny(filepath.Join("docs", "sources", "developers", "kinds", "core")), ) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index cc5d17e0578..120b95f96ef 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -31,7 +31,6 @@ export interface FeatureToggles { featureHighlights?: boolean; migrationLocking?: boolean; storage?: boolean; - k8s?: boolean; exploreMixedDatasource?: boolean; newTraceViewHeader?: boolean; correlations?: boolean; diff --git a/pkg/codegen/jenny_crd_reg.go b/pkg/codegen/jenny_crd_reg.go deleted file mode 100644 index fab3aa5bf1e..00000000000 --- a/pkg/codegen/jenny_crd_reg.go +++ /dev/null @@ -1,63 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path/filepath" - - "github.com/grafana/codejen" - "github.com/grafana/kindsys" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// CRDKindRegistryJenny generates a static registry of the CRD representations -// of core Grafana kinds, layered on top of the publicly consumable generated -// registry in pkg/corekinds. -// -// Path should be the relative path to the directory that will contain the -// generated registry. -func CRDKindRegistryJenny(path string) ManyToOne { - return &crdregjenny{ - path: path, - } -} - -type crdregjenny struct { - path string -} - -func (j *crdregjenny) JennyName() string { - return "CRDKindRegistryJenny" -} - -func (j *crdregjenny) Generate(kinds ...kindsys.Kind) (*codejen.File, error) { - cores := make([]kindsys.Core, 0, len(kinds)) - for _, d := range kinds { - if corekind, is := d.(kindsys.Core); is { - cores = append(cores, corekind) - } - } - if len(cores) == 0 { - return nil, nil - } - - buf := new(bytes.Buffer) - if err := tmpls.Lookup("core_crd_registry.tmpl").Execute(buf, tvars_kind_registry{ - PackageName: "corecrd", - KindPackagePrefix: filepath.ToSlash(filepath.Join("github.com/grafana/grafana", cuectx.GoCoreKindParentPath)), - Kinds: cores, - }); err != nil { - return nil, fmt.Errorf("failed executing core crd registry template: %w", err) - } - - b, err := postprocessGoFile(genGoFile{ - path: j.path, - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - - return codejen.NewFile(filepath.Join(j.path, "registry_gen.go"), b, j), nil -} diff --git a/pkg/codegen/jenny_crd_types.go b/pkg/codegen/jenny_crd_types.go deleted file mode 100644 index 63cfd3e2b83..00000000000 --- a/pkg/codegen/jenny_crd_types.go +++ /dev/null @@ -1,51 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path/filepath" - - "github.com/grafana/codejen" - "github.com/grafana/kindsys" -) - -// CRDTypesJenny generates the OpenAPI CRD representation for a core -// structured kind that is expected by Kubernetes controller machinery. -func CRDTypesJenny(path string) OneToOne { - return crdTypesJenny{ - parentpath: path, - } -} - -type crdTypesJenny struct { - parentpath string -} - -func (j crdTypesJenny) JennyName() string { - return "CRDTypesJenny" -} - -func (j crdTypesJenny) Generate(kind kindsys.Kind) (*codejen.File, error) { - _, isCore := kind.(kindsys.Core) - _, isCustom := kind.(kindsys.Core) - if !(isCore || isCustom) { - return nil, nil - } - - buf := new(bytes.Buffer) - if err := tmpls.Lookup("core_crd_types.tmpl").Execute(buf, kind); err != nil { - return nil, fmt.Errorf("failed executing crd types template: %w", err) - } - - name := kind.Props().Common().MachineName - path := filepath.Join(j.parentpath, name, "crd", name+"_crd_gen.go") - b, err := postprocessGoFile(genGoFile{ - path: path, - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - - return codejen.NewFile(path, b, j), nil -} diff --git a/pkg/codegen/jenny_crd_yaml.go b/pkg/codegen/jenny_crd_yaml.go deleted file mode 100644 index 9abfb708d97..00000000000 --- a/pkg/codegen/jenny_crd_yaml.go +++ /dev/null @@ -1,215 +0,0 @@ -package codegen - -import ( - "bytes" - "fmt" - "path/filepath" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/ast" - "cuelang.org/go/encoding/openapi" - cueyaml "cuelang.org/go/pkg/encoding/yaml" - "github.com/grafana/codejen" - "github.com/grafana/kindsys/k8ssys" - "github.com/grafana/thema" - goyaml "gopkg.in/yaml.v3" - - "github.com/grafana/kindsys" -) - -// TODO this jenny is quite sloppy, having been quickly adapted from app-sdk. It needs love - -// YamlCRDJenny generates a representation of a core structured kind in YAML CRD form. -func YamlCRDJenny(path string) OneToOne { - return yamlCRDJenny{ - parentpath: path, - } -} - -type yamlCRDJenny struct { - parentpath string -} - -func (yamlCRDJenny) JennyName() string { - return "YamlCRDJenny" -} - -func (j yamlCRDJenny) Generate(k kindsys.Kind) (*codejen.File, error) { - kind, is := k.(kindsys.Core) - if !is { - return nil, nil - } - - props := kind.Def().Properties - lin := kind.Lineage() - - // We need to go through every schema, as they all have to be defined in the CRD - sch, err := lin.Schema(thema.SV(0, 0)) - if err != nil { - return nil, err - } - - resource := customResourceDefinition{ - APIVersion: "apiextensions.k8s.io/v1", - Kind: "CustomResourceDefinition", - Metadata: customResourceDefinitionMetadata{ - Name: fmt.Sprintf("%s.%s", props.PluralMachineName, props.CRD.Group), - }, - Spec: k8ssys.CustomResourceDefinitionSpec{ - Group: props.CRD.Group, - Scope: props.CRD.Scope, - Names: k8ssys.CustomResourceDefinitionSpecNames{ - Kind: props.Name, - Plural: props.PluralMachineName, - }, - Versions: make([]k8ssys.CustomResourceDefinitionSpecVersion, 0), - }, - } - latest := lin.Latest().Version() - - for sch != nil { - oapi, err := generateOpenAPI(sch, props) - if err != nil { - return nil, err - } - - vstr := versionString(sch.Version()) - if props.Maturity.Less(kindsys.MaturityStable) { - vstr = "v0-0alpha1" - } - - ver, err := valueToCRDSpecVersion(oapi, vstr, sch.Version() == latest) - if err != nil { - return nil, err - } - if props.CRD.DummySchema { - ver.Schema = map[string]any{ - "openAPIV3Schema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "spec": map[string]any{ - "type": "object", - "x-kubernetes-preserve-unknown-fields": true, - }, - }, - "required": []any{ - "spec", - }, - }, - } - } - - resource.Spec.Versions = append(resource.Spec.Versions, ver) - sch = sch.Successor() - } - contents, err := goyaml.Marshal(resource) - if err != nil { - return nil, err - } - if props.CRD.DummySchema { - // Add a comment header for those with dummy schema - b := new(bytes.Buffer) - fmt.Fprintf(b, "# This CRD is generated with an empty schema body because Grafana's\n# code generators currently produce OpenAPI that Kubernetes will not\n# accept, despite being valid.\n\n%s", string(contents)) - contents = b.Bytes() - } - - return codejen.NewFile(filepath.Join(j.parentpath, props.MachineName, "crd", props.MachineName+".crd.yml"), contents, j), nil -} - -// customResourceDefinition differs from k8ssys.CustomResourceDefinition in that it doesn't use the metav1 -// TypeMeta and ObjectMeta, as those do not contain YAML tags and get improperly serialized to YAML. -// Since we don't need to use it with the kubernetes go-client, we don't need the extra functionality attached. -// -//nolint:lll -type customResourceDefinition struct { - Kind string `json:"kind,omitempty" yaml:"kind,omitempty" protobuf:"bytes,1,opt,name=kind"` - APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty" protobuf:"bytes,2,opt,name=apiVersion"` - Metadata customResourceDefinitionMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` - Spec k8ssys.CustomResourceDefinitionSpec `json:"spec"` -} - -type customResourceDefinitionMetadata struct { - Name string `json:"name,omitempty" yaml:"name" protobuf:"bytes,1,opt,name=name"` - // TODO: other fields as necessary for codegen -} - -type cueOpenAPIEncoded struct { - Components cueOpenAPIEncodedComponents `json:"components"` -} - -type cueOpenAPIEncodedComponents struct { - Schemas map[string]any `json:"schemas"` -} - -func valueToCRDSpecVersion(str string, name string, stored bool) (k8ssys.CustomResourceDefinitionSpecVersion, error) { - // Decode the bytes back into an object where we can trim the openAPI clutter out - // and grab just the schema as a map[string]any (which is what k8s wants) - back := cueOpenAPIEncoded{} - err := goyaml.Unmarshal([]byte(str), &back) - if err != nil { - return k8ssys.CustomResourceDefinitionSpecVersion{}, err - } - if len(back.Components.Schemas) != 1 { - // There should only be one schema here... - // TODO: this may change with subresources--but subresources should have defined names - return k8ssys.CustomResourceDefinitionSpecVersion{}, fmt.Errorf("version %s has multiple schemas", name) - } - var def map[string]any - for _, v := range back.Components.Schemas { - ok := false - def, ok = v.(map[string]any) - if !ok { - return k8ssys.CustomResourceDefinitionSpecVersion{}, - fmt.Errorf("error generating openapi schema - generated schema has invalid type") - } - } - - return k8ssys.CustomResourceDefinitionSpecVersion{ - Name: name, - Served: true, - Storage: stored, - Schema: map[string]any{ - "openAPIV3Schema": map[string]any{ - "properties": map[string]any{ - "spec": def, - }, - "required": []any{ - "spec", - }, - "type": "object", - }, - }, - }, nil -} - -func versionString(version thema.SyntacticVersion) string { - return fmt.Sprintf("v%d-%d", version[0], version[1]) -} - -// Hoisting this out of thema until we resolve the proper approach there -func generateOpenAPI(sch thema.Schema, props kindsys.CoreProperties) (string, error) { - ctx := sch.Underlying().Context() - v := ctx.CompileString(fmt.Sprintf("#%s: _", props.Name)) - defpath := cue.MakePath(cue.Def(props.Name)) - defsch := v.FillPath(defpath, sch.Underlying()) - - cfg := &openapi.Config{ - NameFunc: func(v cue.Value, path cue.Path) string { - if path.String() == defpath.String() { - return props.Name - } - return "" - }, - Info: ast.NewStruct( // doesn't matter, we're throwing it away - "title", ast.NewString(props.Name), - "version", ast.NewString("0.0"), - ), - } - - f, err := openapi.Generate(defsch, cfg) - if err != nil { - return "", err - } - - return cueyaml.Marshal(sch.Lineage().Runtime().Context().BuildFile(f)) -} diff --git a/pkg/codegen/tmpl/core_crd_registry.tmpl b/pkg/codegen/tmpl/core_crd_registry.tmpl deleted file mode 100644 index f30bb443232..00000000000 --- a/pkg/codegen/tmpl/core_crd_registry.tmpl +++ /dev/null @@ -1,66 +0,0 @@ -package {{ .PackageName }} - -import ( - "encoding/json" - "fmt" - - {{range .Kinds }} - {{ .Props.MachineName }} "{{ $.KindPackagePrefix }}/{{ .Props.MachineName }}/crd"{{end}} - "github.com/grafana/kindsys" - "github.com/grafana/kindsys/k8ssys" - "github.com/grafana/grafana/pkg/registry/corekind" - "gopkg.in/yaml.v3" -) - -// Registry is a list of all of Grafana's core structured kinds, wrapped in a -// standard [k8ssys.CRD] interface that makes them usable for interactions -// with certain Kubernetes controller and apimachinery libraries. -// -// There are two access methods: individually via literal named methods, or as -// a slice returned from All() method. -// -// Prefer the individual named methods for use cases where the particular kind(s) -// that are needed are known to the caller. Prefer All() when performing operations -// generically across all kinds. -type Registry struct { - all [{{ len .Kinds }}]k8ssys.Kind -} - -{{range $i, $k := .Kinds }} -// {{ .Props.Name }} returns the [k8ssys.Kind] instance for the {{ .Props.Name }} kind. -func (r *Registry) {{ .Props.Name }}() k8ssys.Kind { - return r.all[{{ $i }}] -} -{{end}} - -func doNewRegistry(breg *corekind.Base) *Registry { - var err error - var b []byte - var kk k8ssys.Kind - reg := &Registry{} - -{{range $i, $k := .Kinds }} - kk = k8ssys.Kind{ - GrafanaKind: breg.{{ $k.Props.Name }}(), - Object: &{{ $k.Props.MachineName }}.{{ $k.Props.Name }}{}, - ObjectList: &{{ $k.Props.MachineName }}.{{ $k.Props.Name }}List{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map{{ $i }} := make(map[string]any) - err = yaml.Unmarshal({{ $k.Props.MachineName }}.CRDYaml, map{{ $i }}) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for {{ $k.Props.Name }} failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map{{ $i }}) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for {{ $k.Props.Name }}: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for {{ $k.Props.Name }}: %s", err)) - } - reg.all[{{ $i }}] = kk -{{end}} - - return reg -} diff --git a/pkg/codegen/tmpl/core_crd_types.tmpl b/pkg/codegen/tmpl/core_crd_types.tmpl deleted file mode 100644 index f7d66402f82..00000000000 --- a/pkg/codegen/tmpl/core_crd_types.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/{{ .Props.MachineName }}" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the {{ .Props.Name }} kind. -// -//go:embed {{ .Props.MachineName }}.crd.yml -var CRDYaml []byte - -// {{ .Props.Name }} is the Go CRD representation of a single {{ .Props.Name }} object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type {{ .Props.Name }} struct { - k8ssys.Base[{{ .Props.MachineName }}.{{ .Props.Name }}] -} - -// {{ .Props.Name }}List is the Go CRD representation of a list {{ .Props.Name }} objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type {{ .Props.Name }}List struct { - k8ssys.ListBase[{{ .Props.MachineName }}.{{ .Props.Name }}] -} diff --git a/pkg/kinds/dashboard/crd/dashboard.crd.yml b/pkg/kinds/dashboard/crd/dashboard.crd.yml deleted file mode 100644 index 69bb59bbcaf..00000000000 --- a/pkg/kinds/dashboard/crd/dashboard.crd.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -# This CRD is generated with an empty schema body because Grafana's -# code generators currently produce OpenAPI that Kubernetes will not -# accept, despite being valid. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: dashboards.dashboard.core.grafana.com -spec: - group: dashboard.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - type: object - x-kubernetes-preserve-unknown-fields: true - required: - - spec - type: object - names: - kind: Dashboard - plural: dashboards - scope: Namespaced diff --git a/pkg/kinds/dashboard/crd/dashboard_crd_gen.go b/pkg/kinds/dashboard/crd/dashboard_crd_gen.go deleted file mode 100644 index a9a5af9730d..00000000000 --- a/pkg/kinds/dashboard/crd/dashboard_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/dashboard" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the Dashboard kind. -// -//go:embed dashboard.crd.yml -var CRDYaml []byte - -// Dashboard is the Go CRD representation of a single Dashboard object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type Dashboard struct { - k8ssys.Base[dashboard.Dashboard] -} - -// DashboardList is the Go CRD representation of a list Dashboard objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type DashboardList struct { - k8ssys.ListBase[dashboard.Dashboard] -} diff --git a/pkg/kinds/librarypanel/crd/librarypanel.crd.yml b/pkg/kinds/librarypanel/crd/librarypanel.crd.yml deleted file mode 100644 index 387b40fc12a..00000000000 --- a/pkg/kinds/librarypanel/crd/librarypanel.crd.yml +++ /dev/null @@ -1,122 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: librarypanels.librarypanel.core.grafana.com -spec: - group: librarypanel.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - description: - description: Panel description - type: string - folderUid: - description: Folder UID - type: string - meta: - description: Object storage metadata - properties: - connectedDashboards: - format: int64 - type: integer - created: - format: date-time - type: string - createdBy: - properties: - avatarUrl: - type: string - id: - format: int64 - type: integer - name: - type: string - required: - - id - - name - - avatarUrl - type: object - folderName: - type: string - folderUid: - type: string - updated: - format: date-time - type: string - updatedBy: - properties: - avatarUrl: - type: string - id: - format: int64 - type: integer - name: - type: string - required: - - id - - name - - avatarUrl - type: object - required: - - folderName - - folderUid - - connectedDashboards - - created - - updated - - createdBy - - updatedBy - type: object - model: - description: |- - TODO: should be the same panel schema defined in dashboard - Typescript: Omit; - type: object - name: - description: Panel name (also saved in the model) - minLength: 1 - type: string - schemaVersion: - description: Dashboard version when this was saved (zero if unknown) - maximum: 65535 - minimum: 0 - type: integer - type: - description: The panel type (from inside the model) - minLength: 1 - type: string - uid: - description: Library element UID - type: string - version: - description: panel version, incremented each time the dashboard is updated. - format: int64 - type: integer - required: - - uid - - name - - type - - version - - model - type: object - required: - - spec - type: object - names: - kind: LibraryPanel - plural: librarypanels - scope: Namespaced diff --git a/pkg/kinds/librarypanel/crd/librarypanel_crd_gen.go b/pkg/kinds/librarypanel/crd/librarypanel_crd_gen.go deleted file mode 100644 index 3f538a327bf..00000000000 --- a/pkg/kinds/librarypanel/crd/librarypanel_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/librarypanel" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the LibraryPanel kind. -// -//go:embed librarypanel.crd.yml -var CRDYaml []byte - -// LibraryPanel is the Go CRD representation of a single LibraryPanel object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type LibraryPanel struct { - k8ssys.Base[librarypanel.LibraryPanel] -} - -// LibraryPanelList is the Go CRD representation of a list LibraryPanel objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type LibraryPanelList struct { - k8ssys.ListBase[librarypanel.LibraryPanel] -} diff --git a/pkg/kinds/playlist/crd/playlist.crd.yml b/pkg/kinds/playlist/crd/playlist.crd.yml deleted file mode 100644 index 192d8945ce8..00000000000 --- a/pkg/kinds/playlist/crd/playlist.crd.yml +++ /dev/null @@ -1,82 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: playlists.playlist.core.grafana.com -spec: - group: playlist.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - interval: - default: 5m - description: |- - 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? - type: string - items: - description: |- - 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: - properties: - title: - description: Title is an unused property -- it will be removed in the future - type: string - type: - description: Type of the item. - enum: - - dashboard_by_uid - - dashboard_by_id - - dashboard_by_tag - type: string - value: - description: |- - 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 - type: string - required: - - type - - value - type: object - type: array - name: - description: Name of the playlist. - type: string - uid: - description: |- - Unique playlist identifier. Generated on creation, either by the - creator of the playlist of by the application. - type: string - required: - - uid - - name - - interval - type: object - required: - - spec - type: object - names: - kind: Playlist - plural: playlists - scope: Namespaced diff --git a/pkg/kinds/playlist/crd/playlist_crd_gen.go b/pkg/kinds/playlist/crd/playlist_crd_gen.go deleted file mode 100644 index b05148c5a5a..00000000000 --- a/pkg/kinds/playlist/crd/playlist_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/playlist" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the Playlist kind. -// -//go:embed playlist.crd.yml -var CRDYaml []byte - -// Playlist is the Go CRD representation of a single Playlist object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type Playlist struct { - k8ssys.Base[playlist.Playlist] -} - -// PlaylistList is the Go CRD representation of a list Playlist objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type PlaylistList struct { - k8ssys.ListBase[playlist.Playlist] -} diff --git a/pkg/kinds/preferences/crd/preferences.crd.yml b/pkg/kinds/preferences/crd/preferences.crd.yml deleted file mode 100644 index 5afabc84e8c..00000000000 --- a/pkg/kinds/preferences/crd/preferences.crd.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: preferencess.preferences.core.grafana.com -spec: - group: preferences.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - homeDashboardUID: - description: UID for the home dashboard - type: string - language: - description: Selected language (beta) - type: string - queryHistory: - description: Explore query history preferences - properties: - homeTab: - description: 'one of: '''' | ''query'' | ''starred'';' - type: string - type: object - theme: - description: light, dark, empty is default - type: string - timezone: - description: |- - The timezone selection - TODO: this should use the timezone defined in common - type: string - weekStart: - description: day of the week (sunday, monday, etc) - type: string - type: object - required: - - spec - type: object - names: - kind: Preferences - plural: preferencess - scope: Namespaced diff --git a/pkg/kinds/preferences/crd/preferences_crd_gen.go b/pkg/kinds/preferences/crd/preferences_crd_gen.go deleted file mode 100644 index 7ccaa2380a7..00000000000 --- a/pkg/kinds/preferences/crd/preferences_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/preferences" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the Preferences kind. -// -//go:embed preferences.crd.yml -var CRDYaml []byte - -// Preferences is the Go CRD representation of a single Preferences object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type Preferences struct { - k8ssys.Base[preferences.Preferences] -} - -// PreferencesList is the Go CRD representation of a list Preferences objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type PreferencesList struct { - k8ssys.ListBase[preferences.Preferences] -} diff --git a/pkg/kinds/publicdashboard/crd/publicdashboard.crd.yml b/pkg/kinds/publicdashboard/crd/publicdashboard.crd.yml deleted file mode 100644 index 70fb2292b93..00000000000 --- a/pkg/kinds/publicdashboard/crd/publicdashboard.crd.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: publicdashboards.publicdashboard.core.grafana.com -spec: - group: publicdashboard.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - accessToken: - description: Unique public access token - type: string - annotationsEnabled: - description: Flag that indicates if annotations are enabled - type: boolean - dashboardUid: - description: Dashboard unique identifier referenced by this public dashboard - type: string - isEnabled: - description: Flag that indicates if the public dashboard is enabled - type: boolean - timeSelectionEnabled: - description: Flag that indicates if the time range picker is enabled - type: boolean - uid: - description: Unique public dashboard identifier - type: string - required: - - uid - - dashboardUid - - isEnabled - - annotationsEnabled - - timeSelectionEnabled - type: object - required: - - spec - type: object - names: - kind: PublicDashboard - plural: publicdashboards - scope: Namespaced diff --git a/pkg/kinds/publicdashboard/crd/publicdashboard_crd_gen.go b/pkg/kinds/publicdashboard/crd/publicdashboard_crd_gen.go deleted file mode 100644 index c59e2fc1f5b..00000000000 --- a/pkg/kinds/publicdashboard/crd/publicdashboard_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/publicdashboard" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the PublicDashboard kind. -// -//go:embed publicdashboard.crd.yml -var CRDYaml []byte - -// PublicDashboard is the Go CRD representation of a single PublicDashboard object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type PublicDashboard struct { - k8ssys.Base[publicdashboard.PublicDashboard] -} - -// PublicDashboardList is the Go CRD representation of a list PublicDashboard objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type PublicDashboardList struct { - k8ssys.ListBase[publicdashboard.PublicDashboard] -} diff --git a/pkg/kinds/serviceaccount/crd/serviceaccount.crd.yml b/pkg/kinds/serviceaccount/crd/serviceaccount.crd.yml deleted file mode 100644 index 31c2131b9e8..00000000000 --- a/pkg/kinds/serviceaccount/crd/serviceaccount.crd.yml +++ /dev/null @@ -1,94 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: serviceaccounts.serviceaccount.core.grafana.com -spec: - group: serviceaccount.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - accessControl: - additionalProperties: - type: boolean - description: AccessControl metadata associated with a given resource. - type: object - avatarUrl: - description: |- - AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front - of the service account. - type: string - created: - description: Created indicates when the service account was created. - format: date-time - type: string - id: - description: ID is the unique identifier of the service account in the database. - format: int64 - type: integer - isDisabled: - description: IsDisabled indicates if the service account is disabled. - type: boolean - login: - description: Login of the service account. - type: string - name: - description: Name of the service account. - type: string - orgId: - description: OrgId is the ID of an organisation the service account belongs to. - format: int64 - type: integer - role: - description: Role is the Grafana organization role of the service account which can be 'Viewer', 'Editor', 'Admin'. - enum: - - Admin - - Editor - - Viewer - type: string - teams: - description: Teams is a list of teams the service account belongs to. - items: - type: string - type: array - tokens: - description: |- - Tokens is the number of active tokens for the service account. - Tokens are used to authenticate the service account against Grafana. - format: int64 - type: integer - updated: - description: Updated indicates when the service account was updated. - format: date-time - type: string - required: - - id - - orgId - - name - - login - - isDisabled - - role - - tokens - - avatarUrl - type: object - required: - - spec - type: object - names: - kind: ServiceAccount - plural: serviceaccounts - scope: Namespaced diff --git a/pkg/kinds/serviceaccount/crd/serviceaccount_crd_gen.go b/pkg/kinds/serviceaccount/crd/serviceaccount_crd_gen.go deleted file mode 100644 index 33f3564a798..00000000000 --- a/pkg/kinds/serviceaccount/crd/serviceaccount_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/serviceaccount" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the ServiceAccount kind. -// -//go:embed serviceaccount.crd.yml -var CRDYaml []byte - -// ServiceAccount is the Go CRD representation of a single ServiceAccount object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type ServiceAccount struct { - k8ssys.Base[serviceaccount.ServiceAccount] -} - -// ServiceAccountList is the Go CRD representation of a list ServiceAccount objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type ServiceAccountList struct { - k8ssys.ListBase[serviceaccount.ServiceAccount] -} diff --git a/pkg/kinds/team/crd/team.crd.yml b/pkg/kinds/team/crd/team.crd.yml deleted file mode 100644 index de995049e83..00000000000 --- a/pkg/kinds/team/crd/team.crd.yml +++ /dev/null @@ -1,77 +0,0 @@ -# Code generated - EDITING IS FUTILE. DO NOT EDIT. -# -# Generated by: -# kinds/gen.go -# Using jennies: -# YamlCRDJenny -# -# Run 'make gen-cue' from repository root to regenerate. - -kind: CustomResourceDefinition -apiVersion: apiextensions.k8s.io/v1 -metadata: - name: teams.team.core.grafana.com -spec: - group: team.core.grafana.com - versions: - - name: v0-0alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - properties: - spec: - properties: - accessControl: - additionalProperties: - type: boolean - description: AccessControl metadata associated with a given resource. - type: object - avatarUrl: - description: AvatarUrl is the team's avatar URL. - type: string - created: - description: Created indicates when the team was created. - format: date-time - type: string - email: - description: Email of the team. - type: string - memberCount: - description: MemberCount is the number of the team members. - format: int64 - type: integer - name: - description: Name of the team. - type: string - orgId: - description: OrgId is the ID of an organisation the team belongs to. - format: int64 - type: integer - permission: - description: TODO - it seems it's a team_member.permission, unlikely it should belong to the team kind - enum: - - 0 - - 1 - - 2 - - 4 - type: integer - updated: - description: Updated indicates when the team was updated. - format: date-time - type: string - required: - - orgId - - name - - memberCount - - permission - - created - - updated - type: object - required: - - spec - type: object - names: - kind: Team - plural: teams - scope: Namespaced diff --git a/pkg/kinds/team/crd/team_crd_gen.go b/pkg/kinds/team/crd/team_crd_gen.go deleted file mode 100644 index a4f41c8d6ce..00000000000 --- a/pkg/kinds/team/crd/team_crd_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDTypesJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package crd - -import ( - _ "embed" - - "github.com/grafana/grafana/pkg/kinds/team" - "github.com/grafana/kindsys/k8ssys" -) - -// The CRD YAML representation of the Team kind. -// -//go:embed team.crd.yml -var CRDYaml []byte - -// Team is the Go CRD representation of a single Team object. -// It implements [runtime.Object], and is used in k8s scheme construction. -type Team struct { - k8ssys.Base[team.Team] -} - -// TeamList is the Go CRD representation of a list Team objects. -// It implements [runtime.Object], and is used in k8s scheme construction. -type TeamList struct { - k8ssys.ListBase[team.Team] -} diff --git a/pkg/registry/corecrd/registry.go b/pkg/registry/corecrd/registry.go deleted file mode 100644 index fa160c057e0..00000000000 --- a/pkg/registry/corecrd/registry.go +++ /dev/null @@ -1,27 +0,0 @@ -package corecrd - -import ( - "github.com/grafana/kindsys/k8ssys" - "github.com/grafana/thema" - - "github.com/grafana/grafana/pkg/registry/corekind" -) - -// New constructs a new [Registry]. -// -// 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 New(rt *thema.Runtime) *Registry { - breg := corekind.NewBase(rt) - return doNewRegistry(breg) -} - -// All returns a slice of all core Grafana CRDs in the registry. -// -// The returned slice is guaranteed to be alphabetically sorted by kind name. -func (r *Registry) All() []k8ssys.Kind { - all := make([]k8ssys.Kind, len(r.all)) - copy(all, r.all[:]) - return all -} diff --git a/pkg/registry/corecrd/registry_gen.go b/pkg/registry/corecrd/registry_gen.go deleted file mode 100644 index 0b07d7a910f..00000000000 --- a/pkg/registry/corecrd/registry_gen.go +++ /dev/null @@ -1,231 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. -// -// Generated by: -// kinds/gen.go -// Using jennies: -// CRDKindRegistryJenny -// -// Run 'make gen-cue' from repository root to regenerate. - -package corecrd - -import ( - "encoding/json" - "fmt" - - dashboard "github.com/grafana/grafana/pkg/kinds/dashboard/crd" - librarypanel "github.com/grafana/grafana/pkg/kinds/librarypanel/crd" - playlist "github.com/grafana/grafana/pkg/kinds/playlist/crd" - preferences "github.com/grafana/grafana/pkg/kinds/preferences/crd" - publicdashboard "github.com/grafana/grafana/pkg/kinds/publicdashboard/crd" - serviceaccount "github.com/grafana/grafana/pkg/kinds/serviceaccount/crd" - team "github.com/grafana/grafana/pkg/kinds/team/crd" - "github.com/grafana/grafana/pkg/registry/corekind" - "github.com/grafana/kindsys/k8ssys" - "gopkg.in/yaml.v3" -) - -// Registry is a list of all of Grafana's core structured kinds, wrapped in a -// standard [k8ssys.CRD] interface that makes them usable for interactions -// with certain Kubernetes controller and apimachinery libraries. -// -// There are two access methods: individually via literal named methods, or as -// a slice returned from All() method. -// -// Prefer the individual named methods for use cases where the particular kind(s) -// that are needed are known to the caller. Prefer All() when performing operations -// generically across all kinds. -type Registry struct { - all [7]k8ssys.Kind -} - -// Dashboard returns the [k8ssys.Kind] instance for the Dashboard kind. -func (r *Registry) Dashboard() k8ssys.Kind { - return r.all[0] -} - -// LibraryPanel returns the [k8ssys.Kind] instance for the LibraryPanel kind. -func (r *Registry) LibraryPanel() k8ssys.Kind { - return r.all[1] -} - -// Playlist returns the [k8ssys.Kind] instance for the Playlist kind. -func (r *Registry) Playlist() k8ssys.Kind { - return r.all[2] -} - -// Preferences returns the [k8ssys.Kind] instance for the Preferences kind. -func (r *Registry) Preferences() k8ssys.Kind { - return r.all[3] -} - -// PublicDashboard returns the [k8ssys.Kind] instance for the PublicDashboard kind. -func (r *Registry) PublicDashboard() k8ssys.Kind { - return r.all[4] -} - -// ServiceAccount returns the [k8ssys.Kind] instance for the ServiceAccount kind. -func (r *Registry) ServiceAccount() k8ssys.Kind { - return r.all[5] -} - -// Team returns the [k8ssys.Kind] instance for the Team kind. -func (r *Registry) Team() k8ssys.Kind { - return r.all[6] -} - -func doNewRegistry(breg *corekind.Base) *Registry { - var err error - var b []byte - var kk k8ssys.Kind - reg := &Registry{} - - kk = k8ssys.Kind{ - GrafanaKind: breg.Dashboard(), - Object: &dashboard.Dashboard{}, - ObjectList: &dashboard.DashboardList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map0 := make(map[string]any) - err = yaml.Unmarshal(dashboard.CRDYaml, map0) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for Dashboard failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map0) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for Dashboard: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for Dashboard: %s", err)) - } - reg.all[0] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.LibraryPanel(), - Object: &librarypanel.LibraryPanel{}, - ObjectList: &librarypanel.LibraryPanelList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map1 := make(map[string]any) - err = yaml.Unmarshal(librarypanel.CRDYaml, map1) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for LibraryPanel failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map1) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for LibraryPanel: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for LibraryPanel: %s", err)) - } - reg.all[1] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.Playlist(), - Object: &playlist.Playlist{}, - ObjectList: &playlist.PlaylistList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map2 := make(map[string]any) - err = yaml.Unmarshal(playlist.CRDYaml, map2) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for Playlist failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map2) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for Playlist: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for Playlist: %s", err)) - } - reg.all[2] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.Preferences(), - Object: &preferences.Preferences{}, - ObjectList: &preferences.PreferencesList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map3 := make(map[string]any) - err = yaml.Unmarshal(preferences.CRDYaml, map3) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for Preferences failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map3) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for Preferences: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for Preferences: %s", err)) - } - reg.all[3] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.PublicDashboard(), - Object: &publicdashboard.PublicDashboard{}, - ObjectList: &publicdashboard.PublicDashboardList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map4 := make(map[string]any) - err = yaml.Unmarshal(publicdashboard.CRDYaml, map4) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for PublicDashboard failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map4) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for PublicDashboard: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for PublicDashboard: %s", err)) - } - reg.all[4] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.ServiceAccount(), - Object: &serviceaccount.ServiceAccount{}, - ObjectList: &serviceaccount.ServiceAccountList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map5 := make(map[string]any) - err = yaml.Unmarshal(serviceaccount.CRDYaml, map5) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for ServiceAccount failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map5) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for ServiceAccount: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for ServiceAccount: %s", err)) - } - reg.all[5] = kk - - kk = k8ssys.Kind{ - GrafanaKind: breg.Team(), - Object: &team.Team{}, - ObjectList: &team.TeamList{}, - } - // TODO Having the committed form on disk in YAML is worth doing this for now...but fix this silliness - map6 := make(map[string]any) - err = yaml.Unmarshal(team.CRDYaml, map6) - if err != nil { - panic(fmt.Sprintf("generated CRD YAML for Team failed to unmarshal: %s", err)) - } - b, err = json.Marshal(map6) - if err != nil { - panic(fmt.Sprintf("could not re-marshal CRD JSON for Team: %s", err)) - } - err = json.Unmarshal(b, &kk.Schema) - if err != nil { - panic(fmt.Sprintf("could not unmarshal CRD JSON for Team: %s", err)) - } - reg.all[6] = kk - - return reg -} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e9dd1654c40..552ec7e3034 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -90,13 +90,6 @@ var ( State: FeatureStateAlpha, Owner: grafanaAppPlatformSquad, }, - { - Name: "k8s", - Description: "Explore native k8s integrations", - State: FeatureStateAlpha, - RequiresDevMode: true, - Owner: grafanaAppPlatformSquad, - }, { Name: "exploreMixedDatasource", Description: "Enable mixed datasource in Explore", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index cd9e05e9489..07bee5a12b9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -12,7 +12,6 @@ lokiLive,alpha,@grafana/observability-logs,false,false,false,false featureHighlights,stable,@grafana/grafana-as-code,false,false,false,false migrationLocking,beta,@grafana/backend-platform,false,false,false,false storage,alpha,@grafana/grafana-app-platform-squad,false,false,false,false -k8s,alpha,@grafana/grafana-app-platform-squad,true,false,false,false exploreMixedDatasource,beta,@grafana/explore-squad,false,false,false,true newTraceViewHeader,alpha,@grafana/observability-traces-and-profiling,false,false,false,true correlations,beta,@grafana/explore-squad,false,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3848bc3527b..d7e369c0dc3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -59,10 +59,6 @@ const ( // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" - // FlagK8S - // Explore native k8s integrations - FlagK8S = "k8s" - // FlagExploreMixedDatasource // Enable mixed datasource in Explore FlagExploreMixedDatasource = "exploreMixedDatasource" From d0ced39847c94a76047c2e79824f1ccb262e3a33 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 26 Apr 2023 16:07:15 +0200 Subject: [PATCH 433/729] Elasticsearch: Use array of strings as index in backend queries (#67276) Elasticsearch: Use array of strings as indice in backend queries --- pkg/tsdb/elasticsearch/client/client.go | 2 +- pkg/tsdb/elasticsearch/client/client_test.go | 2 +- pkg/tsdb/elasticsearch/snapshot_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index c96f51d396b..c14ffd6a8b0 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -207,7 +207,7 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque header: map[string]interface{}{ "search_type": "query_then_fetch", "ignore_unavailable": true, - "index": strings.Join(c.indices, ","), + "index": c.indices, }, body: searchReq, interval: searchReq.Interval, diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index 1c4895b7a5d..4d90fd71a7f 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -96,7 +96,7 @@ func TestClient_ExecuteMultisearch(t *testing.T) { jBody, err := simplejson.NewJson(bodyBytes) require.NoError(t, err) - assert.Equal(t, "metrics-2018.05.15", jHeader.Get("index").MustString()) + assert.Equal(t, []string{"metrics-2018.05.15"}, jHeader.Get("index").MustStringArray()) assert.True(t, jHeader.Get("ignore_unavailable").MustBool(false)) assert.Equal(t, "query_then_fetch", jHeader.Get("search_type").MustString()) assert.Empty(t, jHeader.Get("max_concurrent_shard_requests")) diff --git a/pkg/tsdb/elasticsearch/snapshot_test.go b/pkg/tsdb/elasticsearch/snapshot_test.go index ff021dbac8a..312d068f0e1 100644 --- a/pkg/tsdb/elasticsearch/snapshot_test.go +++ b/pkg/tsdb/elasticsearch/snapshot_test.go @@ -82,7 +82,7 @@ func TestRequestSnapshots(t *testing.T) { queryHeader := []byte(` { "ignore_unavailable": true, - "index": "testdb-2022.11.14", + "index": ["testdb-2022.11.14"], "search_type": "query_then_fetch" } `) From 9796b6b0005c53f70eed6f26a7bf2c2212072019 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 26 Apr 2023 15:42:25 +0100 Subject: [PATCH 434/729] NestedFolders: Connect Search input fields to state manager (#67193) * NestedFolders: Connect Search input fields to state manager * Fix tag list not loading * Clear includePanels checkbox when leaving search * fix test * Fix extra right margin * fix missing style * cleanup * fix placeholder * fix test --- .../BrowseDashboardsPage.test.tsx | 2 +- .../BrowseDashboardsPage.tsx | 38 ++++++++++++------ .../components/BrowseFilters.tsx | 40 +++++++++---------- .../components/SearchView.tsx | 22 +++++----- .../search/components/ManageDashboardsNew.tsx | 8 +--- .../search/page/components/ActionRow.tsx | 37 ++++++++--------- .../search/page/components/ManageActions.tsx | 20 +++++----- .../search/page/components/SearchView.tsx | 2 +- .../search/state/SearchStateManager.ts | 22 ++++++++-- public/app/features/search/tempI18nPhrases.ts | 10 +++++ public/locales/en-US/grafana.json | 4 +- public/locales/fr-FR/grafana.json | 10 ++--- public/locales/pseudo-LOCALE/grafana.json | 4 +- 13 files changed, 121 insertions(+), 98 deletions(-) create mode 100644 public/app/features/search/tempI18nPhrases.ts diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index 29f63d4ea30..78d416cae1c 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -61,7 +61,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => { it('displays a search input', async () => { render(); - expect(await screen.findByPlaceholderText('Search box')).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('Search for dashboards and folders')).toBeInTheDocument(); }); it('displays the filters and hides the actions initially', async () => { diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 34c13125aa3..336e109215f 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,15 +1,15 @@ import { css } from '@emotion/css'; -import React, { memo, useMemo } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { locationSearchToObject } from '@grafana/runtime'; -import { Input, useStyles2 } from '@grafana/ui'; +import { FilterInput, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { buildNavModel } from '../folders/state/navModel'; -import { parseRouteParams } from '../search/utils'; +import { useSearchStateManager } from '../search/state/SearchStateManager'; +import { getSearchPlaceholder } from '../search/tempI18nPhrases'; import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI'; import { BrowseActions } from './components/BrowseActions/BrowseActions'; @@ -27,13 +27,22 @@ export interface Props extends GrafanaRouteComponentProps { - const styles = useStyles2(getStyles); +const BrowseDashboardsPage = memo(({ match }: Props) => { const { uid: folderUID } = match.params; - const searchState = useMemo(() => { - return parseRouteParams(locationSearchToObject(location.search)); - }, [location.search]); + const styles = useStyles2(getStyles); + const [searchState, stateManager] = useSearchStateManager(); + const isSearching = stateManager.hasSearchFilters(); + + useEffect(() => stateManager.initStateFromUrl(folderUID), [folderUID, stateManager]); + + useEffect(() => { + // Clear the search results when we leave SearchView to prevent old results flashing + // when starting a new search + if (!isSearching && searchState.result) { + stateManager.setState({ result: undefined, includePanels: undefined }); + } + }, [isSearching, searchState.result, stateManager]); const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken); const navModel = useMemo(() => (folderDTO ? buildNavModel(folderDTO) : undefined), [folderDTO]); @@ -42,15 +51,20 @@ const BrowseDashboardsPage = memo(({ match, location }: Props) => { return ( - + stateManager.onQueryChange(e)} + /> {hasSelection ? : }
{({ width, height }) => - searchState.query ? ( - + isSearching ? ( + ) : ( ) diff --git a/public/app/features/browse-dashboards/components/BrowseFilters.tsx b/public/app/features/browse-dashboards/components/BrowseFilters.tsx index 36fef5bc4fd..34fec506496 100644 --- a/public/app/features/browse-dashboards/components/BrowseFilters.tsx +++ b/public/app/features/browse-dashboards/components/BrowseFilters.tsx @@ -1,33 +1,29 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { ActionRow } from 'app/features/search/page/components/ActionRow'; -import { SearchLayout } from 'app/features/search/types'; +import { getGrafanaSearcher } from 'app/features/search/service'; +import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; export function BrowseFilters() { - const fakeState = useMemo(() => { - return { - query: '', - tag: [], - starred: false, - layout: SearchLayout.Folders, - eventTrackingNamespace: 'manage_dashboards' as const, - }; - }, []); + const [searchState, stateManager] = useSearchStateManager(); return (
Promise.resolve([])} - getSortOptions={() => Promise.resolve([])} - onLayoutChange={() => {}} - onSortChange={() => {}} - onStarredFilterChange={() => {}} - onTagFilterChange={() => {}} - onDatasourceChange={() => {}} - onPanelTypeChange={() => {}} - onSetIncludePanels={() => {}} + hideLayout + showStarredFilter + state={searchState} + getTagOptions={stateManager.getTagOptions} + getSortOptions={getGrafanaSearcher().getSortOptions} + sortPlaceholder={getGrafanaSearcher().sortPlaceholder} + includePanels={searchState.includePanels ?? false} + onLayoutChange={stateManager.onLayoutChange} + onStarredFilterChange={stateManager.onStarredFilterChange} + onSortChange={stateManager.onSortChange} + onTagFilterChange={stateManager.onTagFilterChange} + onDatasourceChange={stateManager.onDatasourceChange} + onPanelTypeChange={stateManager.onPanelTypeChange} + onSetIncludePanels={stateManager.onSetIncludePanels} />
); diff --git a/public/app/features/browse-dashboards/components/SearchView.tsx b/public/app/features/browse-dashboards/components/SearchView.tsx index 481fc7926b0..0af421731f4 100644 --- a/public/app/features/browse-dashboards/components/SearchView.tsx +++ b/public/app/features/browse-dashboards/components/SearchView.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import { Spinner } from '@grafana/ui'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable'; -import { getSearchStateManager } from 'app/features/search/state/SearchStateManager'; +import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; import { DashboardViewItemKind } from 'app/features/search/types'; import { useDispatch, useSelector } from 'app/types'; @@ -12,20 +12,16 @@ import { setItemSelectionState } from '../state'; interface SearchViewProps { height: number; width: number; - folderUID: string | undefined; } -export function SearchView({ folderUID, width, height }: SearchViewProps) { +export function SearchView({ width, height }: SearchViewProps) { const dispatch = useDispatch(); const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems); const { keyboardEvents } = useKeyNavigationListener(); + const [searchState, stateManager] = useSearchStateManager(); - const stateManager = getSearchStateManager(); - useEffect(() => stateManager.initStateFromUrl(folderUID), [folderUID, stateManager]); - - const state = stateManager.useState(); - const value = state.result; + const value = searchState.result; const selectionChecker = useCallback( (kind: string | undefined, uid: string): boolean => { @@ -55,12 +51,16 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) { if (!value) { return ( -
+
); } + if (value.totalRows === 0) { + return
No search results
; + } + const props: SearchResultsProps = { response: value, selection: selectionChecker, @@ -70,7 +70,7 @@ export function SearchView({ folderUID, width, height }: SearchViewProps) { height: height, onTagSelected: stateManager.onAddTag, keyboardEvents, - onDatasourceChange: state.datasource ? stateManager.onDatasourceChange : undefined, + onDatasourceChange: searchState.datasource ? stateManager.onDatasourceChange : undefined, onClickItem: stateManager.onSearchItemClicked, }; diff --git a/public/app/features/search/components/ManageDashboardsNew.tsx b/public/app/features/search/components/ManageDashboardsNew.tsx index bca54ec5a8e..a2065e5c953 100644 --- a/public/app/features/search/components/ManageDashboardsNew.tsx +++ b/public/app/features/search/components/ManageDashboardsNew.tsx @@ -3,13 +3,13 @@ import React, { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, FilterInput } from '@grafana/ui'; -import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { FolderDTO, AccessControlAction } from 'app/types'; import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; import { SearchView } from '../page/components/SearchView'; import { getSearchStateManager } from '../state/SearchStateManager'; +import { getSearchPlaceholder } from '../tempI18nPhrases'; import { DashboardActions } from './DashboardActions'; @@ -50,11 +50,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus spellCheck={false} - placeholder={ - state.includePanels - ? t('search.search-input.include-panels-placeholder', 'Search for dashboards and panels') - : t('search.search-input.placeholder', 'Search for dashboards') - } + placeholder={getSearchPlaceholder(state.includePanels)} escapeRegex={false} className={styles.searchInput} /> diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index f9881c9351f..57fcc7a4b0b 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -105,25 +105,23 @@ export const ActionRow = ({ )} -
- - {!hideLayout && ( - - )} - onSortChange(change?.value)} - value={state.sort} - getSortOptions={getSortOptions} - placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')} - isClearable + + {!hideLayout && ( + - -
+ )} + onSortChange(change?.value)} + value={state.sort} + getSortOptions={getSortOptions} + placeholder={sortPlaceholder || t('search.actions.sort-placeholder', 'Sort')} + isClearable + /> +
); }; @@ -143,9 +141,6 @@ export const getStyles = (theme: GrafanaTheme2) => { width: 100%; } `, - rowContainer: css` - margin-right: ${theme.v1.spacing.md}; - `, checkboxWrapper: css` label { line-height: 1.2; diff --git a/public/app/features/search/page/components/ManageActions.tsx b/public/app/features/search/page/components/ManageActions.tsx index dd8dbe90a2c..563343d0d30 100644 --- a/public/app/features/search/page/components/ManageActions.tsx +++ b/public/app/features/search/page/components/ManageActions.tsx @@ -43,17 +43,15 @@ export function ManageActions({ items, folder, onChange, clearSelection }: Props return (
-
- - - - - -
+ + + + + {isDeleteModalOpen && ( setIsDeleteModalOpen(false)} /> diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx index 71ea47e1e54..b1f65393187 100644 --- a/public/app/features/search/page/components/SearchView.tsx +++ b/public/app/features/search/page/components/SearchView.tsx @@ -115,7 +115,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE } return ( -
+
{({ width, height }) => { const props: SearchResultsProps = { diff --git a/public/app/features/search/state/SearchStateManager.ts b/public/app/features/search/state/SearchStateManager.ts index f4c77b248d3..47a57ade1ed 100644 --- a/public/app/features/search/state/SearchStateManager.ts +++ b/public/app/features/search/state/SearchStateManager.ts @@ -40,7 +40,7 @@ export class SearchStateManager extends StateManagerBase { doSearchWithDebounce = debounce(() => this.doSearch(), 300); lastQuery?: SearchQuery; - initStateFromUrl(folderUid?: string) { + initStateFromUrl(folderUid?: string, doInitialSearch = true) { const stateFromUrl = parseRouteParams(locationService.getSearchObject()); // Force list view when conditions are specified from the URL @@ -54,8 +54,11 @@ export class SearchStateManager extends StateManagerBase { eventTrackingNamespace: folderUid ? 'manage_dashboards' : 'dashboard_search', }); - this.doSearch(); + if (doInitialSearch && this.hasSearchFilters()) { + this.doSearch(); + } } + /** * Updates internal and url state, then triggers a new search */ @@ -162,7 +165,7 @@ export class SearchStateManager extends StateManagerBase { }; hasSearchFilters() { - return this.state.query || this.state.tag.length || this.state.starred || this.state.panel_type; + return this.state.query || this.state.tag.length || this.state.starred || this.state.panel_type || this.state.sort; } getSearchQuery() { @@ -244,7 +247,11 @@ export class SearchStateManager extends StateManagerBase { // This gets the possible tags from within the query results getTagOptions = (): Promise => { - return getGrafanaSearcher().tags(this.lastQuery!); + const query = this.lastQuery ?? { + kind: ['dashboard', 'folder'], + query: '*', + }; + return getGrafanaSearcher().tags(query); }; /** @@ -299,3 +306,10 @@ export function getSearchStateManager() { return stateManager; } + +export function useSearchStateManager() { + const stateManager = getSearchStateManager(); + const state = stateManager.useState(); + + return [state, stateManager] as const; +} diff --git a/public/app/features/search/tempI18nPhrases.ts b/public/app/features/search/tempI18nPhrases.ts new file mode 100644 index 00000000000..07c2abbf0ef --- /dev/null +++ b/public/app/features/search/tempI18nPhrases.ts @@ -0,0 +1,10 @@ +// Temporary place to collect phrases we reuse between new and old browse/search +// TODO: remove this when new Browse Dashboards UI is no longer feature flagged + +import { t } from 'app/core/internationalization'; + +export function getSearchPlaceholder(includePanels = false) { + return includePanels + ? t('search.search-input.include-panels-placeholder', 'Search for dashboards, folders, and panels') + : t('search.search-input.placeholder', 'Search for dashboards and folders'); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bd6610270a1..b8fc661ec47 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -439,8 +439,8 @@ "type-header": "Type" }, "search-input": { - "include-panels-placeholder": "Search for dashboards and panels", - "placeholder": "Search for dashboards" + "include-panels-placeholder": "Search for dashboards, folders, and panels", + "placeholder": "Search for dashboards and folders" } }, "share-modal": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 5172e23636e..51b64acea2a 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -55,7 +55,7 @@ "query-tab": "Requête", "stats-tab": "Statistiques", "subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}", - "title": "Inspecter : {{panelTitle}}" + "title": "Inspecter\u00a0: {{panelTitle}}" }, "inspect-data": { "data-options": "Options de données", @@ -85,7 +85,7 @@ "panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.", "panel-json-label": "Panneau JSON", "select-source": "Sélectionner la source", - "unknown": "Objet inconnu : {{show}}" + "unknown": "Objet inconnu\u00a0: {{show}}" }, "inspect-meta": { "no-inspector": "Pas d'inspecteur de métadonnées" @@ -118,7 +118,7 @@ "contact-admin": "Veuillez contacter votre administrateur pour configurer les sources de données.", "explanation": "Pour visualiser vos données, vous devrez d’abord les connecter.", "new-dashboard": "Nouveau tableau de bord", - "preferred": "Connectez votre source de données préférée :", + "preferred": "Connectez votre source de données préférée\u00a0:", "sampleData": "Ou établissez un nouveau tableau de bord avec des exemples de données", "viewAll": "Afficher tout", "welcome": "Bienvenue aux tableaux de bord Grafana !" @@ -148,7 +148,7 @@ }, "library-panels": { "save": { - "error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"", + "error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"", "success": "Panneau de bibliothèque enregistré" } }, @@ -495,7 +495,7 @@ "info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.", "info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.", "local-button": "Instantané local", - "mistake-message": "Avez-vous commis une erreur ? ", + "mistake-message": "Avez-vous commis une erreur\u00a0? ", "name": "Nom de l'instantané", "timeout": "Délai d’expiration (secondes)", "timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index d1305b90833..6bdd1c43a93 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -439,8 +439,8 @@ "type-header": "Ŧypę" }, "search-input": { - "include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ päʼnęľş", - "placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş" + "include-panels-placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş, ƒőľđęřş, äʼnđ päʼnęľş", + "placeholder": "Ŝęäřčĥ ƒőř đäşĥþőäřđş äʼnđ ƒőľđęřş" } }, "share-modal": { From e1ab9cc9d8c5c060553238620acbc1b0ba0404a4 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 26 Apr 2023 08:30:57 -0700 Subject: [PATCH 435/729] Chore: Remove test type app mode (#66987) --- pkg/setting/setting.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 0ddd92535ee..99525b50361 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -47,7 +47,6 @@ const ( DefaultHTTPAddr = "0.0.0.0" Dev = "development" Prod = "production" - Test = "test" ApplicationName = "Grafana" ) From b0881daf234a0211cc5c486b2dd6402adf545695 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 26 Apr 2023 13:06:18 -0300 Subject: [PATCH 436/729] Alerting: Use URLs in image annotations (#66804) * use tokens or urls in image annotations * improve tests, fix some comments * fix empty tokens * code review changes, check for url before checking for token (support old token formats) --- pkg/services/ngalert/notifier/images.go | 38 +++++++++------- pkg/services/ngalert/notifier/images_test.go | 47 ++++++++++++++++++++ pkg/services/ngalert/notifier/testing.go | 4 ++ pkg/services/ngalert/schedule/compat.go | 12 ++++- pkg/services/ngalert/schedule/compat_test.go | 2 +- pkg/services/ngalert/state/manager.go | 22 ++++----- pkg/services/ngalert/state/state_test.go | 2 +- pkg/services/ngalert/store/image.go | 21 +++++++++ pkg/services/ngalert/store/image_test.go | 21 ++++++++- pkg/services/ngalert/store/testing.go | 12 +++++ 10 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 pkg/services/ngalert/notifier/images_test.go diff --git a/pkg/services/ngalert/notifier/images.go b/pkg/services/ngalert/notifier/images.go index bbeec8b36bb..10a29b00663 100644 --- a/pkg/services/ngalert/notifier/images.go +++ b/pkg/services/ngalert/notifier/images.go @@ -2,7 +2,7 @@ package notifier import ( "context" - "errors" + "strings" "github.com/grafana/alerting/images" @@ -20,21 +20,27 @@ func newImageStore(store store.ImageStore) images.ImageStore { } } -func (i imageStore) GetImage(ctx context.Context, token string) (*images.Image, error) { - image, err := i.store.GetImage(ctx, token) +func (i imageStore) GetImage(ctx context.Context, uri string) (*images.Image, error) { + var ( + image *models.Image + err error + ) + + // Check whether the uri is a URL or a token to know how to query the DB. + if strings.HasPrefix(uri, "http") { + image, err = i.store.GetImageByURL(ctx, uri) + } else { + token := strings.TrimPrefix(uri, "token://") + image, err = i.store.GetImage(ctx, token) + } if err != nil { - if errors.Is(err, models.ErrImageNotFound) { - err = images.ErrImageNotFound - } + return nil, err } - var result *images.Image - if image != nil { - result = &images.Image{ - Token: image.Token, - Path: image.Path, - URL: image.URL, - CreatedAt: image.CreatedAt, - } - } - return result, err + + return &images.Image{ + Token: image.Token, + Path: image.Path, + URL: image.URL, + CreatedAt: image.CreatedAt, + }, nil } diff --git a/pkg/services/ngalert/notifier/images_test.go b/pkg/services/ngalert/notifier/images_test.go new file mode 100644 index 00000000000..ba67ba52cb2 --- /dev/null +++ b/pkg/services/ngalert/notifier/images_test.go @@ -0,0 +1,47 @@ +package notifier + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/stretchr/testify/require" +) + +func TestGetImage(t *testing.T) { + fakeImageStore := store.NewFakeImageStore(t) + store := newImageStore(fakeImageStore) + + t.Run("queries by token when it gets a token", func(tt *testing.T) { + img := models.Image{ + Token: "test", + URL: "http://localhost:1234", + Path: "test.png", + } + err := fakeImageStore.SaveImage(context.Background(), &img) + require.NoError(tt, err) + + savedImg, err := store.GetImage(context.Background(), "token://"+img.Token) + require.NoError(tt, err) + require.Equal(tt, savedImg.Token, img.Token) + require.Equal(tt, savedImg.URL, img.URL) + require.Equal(tt, savedImg.Path, img.Path) + }) + + t.Run("queries by URL when it gets a URL", func(tt *testing.T) { + img := models.Image{ + Token: "test", + Path: "test.png", + URL: "https://test.com/test.png", + } + err := fakeImageStore.SaveImage(context.Background(), &img) + require.NoError(tt, err) + + savedImg, err := store.GetImage(context.Background(), img.URL) + require.NoError(tt, err) + require.Equal(tt, savedImg.Token, img.Token) + require.Equal(tt, savedImg.URL, img.URL) + require.Equal(tt, savedImg.Path, img.Path) + }) +} diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 8dd7e111765..31606030bf6 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -31,6 +31,10 @@ func (f *fakeConfigStore) GetImage(ctx context.Context, token string) (*models.I return nil, models.ErrImageNotFound } +func (f *fakeConfigStore) GetImageByURL(ctx context.Context, url string) (*models.Image, error) { + return nil, models.ErrImageNotFound +} + func (f *fakeConfigStore) GetImages(ctx context.Context, tokens []string) ([]models.Image, []string, error) { return nil, nil, models.ErrImageNotFound } diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index 94f8a14b2a3..14c87ae663a 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -50,7 +50,7 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post } if alertState.Image != nil { - nA[alertingModels.ImageTokenAnnotation] = alertState.Image.Token + nA[alertingModels.ImageTokenAnnotation] = generateImageURI(alertState.Image) } if alertState.StateReason != "" { @@ -167,3 +167,13 @@ func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL } return alerts } + +// generateImageURI returns a string that serves as an identifier for the image. +// It first checks if there is an image URL available, and if not, +// it prefixes the image token with `token://` and uses it as the URI. +func generateImageURI(image *ngModels.Image) string { + if image.URL != "" { + return image.URL + } + return "token://" + image.Token +} diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/schedule/compat_test.go index 419f79795ea..7e61fd3d055 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/schedule/compat_test.go @@ -130,7 +130,7 @@ func Test_stateToPostableAlert(t *testing.T) { for k, v := range alertState.Annotations { expected[k] = v } - expected["__alertImageToken__"] = alertState.Image.Token + expected["__alertImageToken__"] = "token://" + alertState.Image.Token require.Equal(t, expected, result.Annotations) }) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 5161ba957cf..15e4ff66a52 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -422,8 +422,6 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { 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 - staleStates := st.cache.deleteRuleStates(alertRule.GetKey(), func(s *State) bool { return stateIsStale(evaluatedAt, s.LastEvaluationTime, alertRule.IntervalSeconds) }) @@ -441,19 +439,15 @@ func (st *Manager) deleteStaleStatesFromCache(ctx context.Context, logger log.Lo 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, alertRule) - if err != nil { - logger.Warn("Failed to take an image", - "dashboard", alertRule.GetDashboardUID(), - "panel", alertRule.GetPanelID(), - "error", err) - } else if image != nil { - resolvedImage = image - } + image, err := takeImage(ctx, st.images, alertRule) + if err != nil { + logger.Warn("Failed to take an image", + "dashboard", alertRule.GetDashboardUID(), + "panel", alertRule.GetPanelID(), + "error", err) + } else if image != nil { + s.Image = image } - s.Image = resolvedImage } record := StateTransition{ diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index 95ced7f8fe3..9d4684e45bc 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -584,7 +584,7 @@ func TestShouldTakeImage(t *testing.T) { name: "should not take image for alerting state with image", state: eval.Alerting, previousState: eval.Alerting, - previousImage: &ngmodels.Image{Path: "foo.png", URL: "https://example.com/foo.png"}, + previousImage: &ngmodels.Image{URL: "https://example.com/foo.png"}, }} for _, test := range tests { diff --git a/pkg/services/ngalert/store/image.go b/pkg/services/ngalert/store/image.go index 7d5a9f44b7b..095098138bb 100644 --- a/pkg/services/ngalert/store/image.go +++ b/pkg/services/ngalert/store/image.go @@ -20,6 +20,10 @@ type ImageStore interface { // if the image has expired or if an image with the token does not exist. GetImage(ctx context.Context, token string) (*models.Image, error) + // GetImageByURL looks for a image by its URL. It returns ErrImageNotFound + // if the image has expired or if there is no image associated with the URL. + GetImageByURL(ctx context.Context, url string) (*models.Image, error) + // GetImages returns all images that match the tokens. If one or more images // have expired or do not exist then it also returns the unmatched tokens // and an ErrImageNotFound error. @@ -54,6 +58,23 @@ func (st DBstore) GetImage(ctx context.Context, token string) (*models.Image, er return &image, nil } +func (st DBstore) GetImageByURL(ctx context.Context, url string) (*models.Image, error) { + var image models.Image + if err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + exists, err := sess.Where("url = ? AND expires_at > ?", url, TimeNow().UTC()).Limit(1).Get(&image) + if err != nil { + return fmt.Errorf("failed to get image: %w", err) + } else if !exists { + return models.ErrImageNotFound + } else { + return nil + } + }); err != nil { + return nil, err + } + return &image, nil +} + func (st DBstore) GetImages(ctx context.Context, tokens []string) ([]models.Image, []string, error) { var images []models.Image if err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/store/image_test.go b/pkg/services/ngalert/store/image_test.go index 8002898d0ba..1f69e2ee7d1 100644 --- a/pkg/services/ngalert/store/image_test.go +++ b/pkg/services/ngalert/store/image_test.go @@ -30,7 +30,7 @@ func TestIntegrationSaveAndGetImage(t *testing.T) { // create an image with a path on disk image1 := models.Image{Path: "example.png"} require.NoError(t, dbstore.SaveImage(ctx, &image1)) - require.NotEqual(t, "", image1.Token) + require.NotEqual(t, image1.Token, "") // image should not have expired assert.False(t, image1.HasExpired()) @@ -49,7 +49,12 @@ func TestIntegrationSaveAndGetImage(t *testing.T) { // create an image with a URL image2 := models.Image{URL: "https://example.com/example.png"} require.NoError(t, dbstore.SaveImage(ctx, &image2)) - require.NotEqual(t, "", image2.Token) + require.NotEqual(t, image2.Token, "") + + // create another image with the same URL + image3 := models.Image{URL: "https://example.com/example.png"} + require.NoError(t, dbstore.SaveImage(ctx, &image3)) + require.NotEqual(t, image3.Token, "") // image should not have expired assert.False(t, image2.HasExpired()) @@ -60,12 +65,24 @@ func TestIntegrationSaveAndGetImage(t *testing.T) { require.NoError(t, err) assert.Equal(t, image2, *result2) + // querying by URL should yield the same result even though we have two images with the same URL + result2, err = dbstore.GetImageByURL(ctx, image2.URL) + require.NoError(t, err) + assert.Equal(t, image2, *result2) + // expired image should not be returned image1.ExpiresAt = time.Now().Add(-time.Second) require.NoError(t, dbstore.SaveImage(ctx, &image1)) result1, err = dbstore.GetImage(ctx, image1.Token) assert.EqualError(t, err, "image not found") assert.Nil(t, result1) + + // Querying by URL should yield the same result. + image2.ExpiresAt = time.Now().Add(-time.Second) + require.NoError(t, dbstore.SaveImage(ctx, &image1)) + result2, err = dbstore.GetImage(ctx, image2.URL) + assert.EqualError(t, err, "image not found") + assert.Nil(t, result2) } func TestIntegrationGetImages(t *testing.T) { diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 5480f443867..6d28dd7005e 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -49,6 +49,18 @@ func (s *FakeImageStore) GetImage(_ context.Context, token string) (*models.Imag return nil, models.ErrImageNotFound } +func (s *FakeImageStore) GetImageByURL(_ context.Context, url string) (*models.Image, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + for _, image := range s.images { + if image.URL == url { + return image, nil + } + } + + return nil, models.ErrImageNotFound +} + func (s *FakeImageStore) GetImages(_ context.Context, tokens []string) ([]models.Image, []string, error) { s.mtx.Lock() defer s.mtx.Unlock() From c308118fc024eae0a7bf48a0b681e234e37262c3 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 26 Apr 2023 18:21:04 +0200 Subject: [PATCH 437/729] Elasticsearch: Move response parsing tests to 1 file (#67288) * Organize tests * Organize * Fix lint * Fix lint --- .../response_parser_frontend_test.go | 1552 -------- .../elasticsearch/response_parser_test.go | 3506 ++++++++++++----- 2 files changed, 2509 insertions(+), 2549 deletions(-) delete mode 100644 pkg/tsdb/elasticsearch/response_parser_frontend_test.go diff --git a/pkg/tsdb/elasticsearch/response_parser_frontend_test.go b/pkg/tsdb/elasticsearch/response_parser_frontend_test.go deleted file mode 100644 index e3b8f3d7305..00000000000 --- a/pkg/tsdb/elasticsearch/response_parser_frontend_test.go +++ /dev/null @@ -1,1552 +0,0 @@ -package elasticsearch - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/require" -) - -func requireTimeValue(t *testing.T, expected int64, frame *data.Frame, index int) { - getField := func() *data.Field { - for _, field := range frame.Fields { - if field.Type() == data.FieldTypeTime { - return field - } - } - return nil - } - - field := getField() - require.NotNil(t, field, "missing time-field") - - require.Equal(t, time.UnixMilli(expected).UTC(), field.At(index), fmt.Sprintf("wrong time at index %v", index)) -} - -func requireNumberValue(t *testing.T, expected float64, frame *data.Frame, index int) { - getField := func() *data.Field { - for _, field := range frame.Fields { - if field.Type() == data.FieldTypeNullableFloat64 { - return field - } - } - return nil - } - - field := getField() - require.NotNil(t, field, "missing number-field") - - v := field.At(index).(*float64) - - require.Equal(t, expected, *v, fmt.Sprintf("wrong number at index %v", index)) -} - -func requireFrameLength(t *testing.T, frame *data.Frame, expectedLength int) { - l, err := frame.RowLen() - require.NoError(t, err) - require.Equal(t, expectedLength, l, "wrong frame-length") -} - -func requireStringAt(t *testing.T, expected string, field *data.Field, index int) { - v := field.At(index).(*string) - require.Equal(t, expected, *v, fmt.Sprintf("wrong string at index %v", index)) -} - -func requireFloatAt(t *testing.T, expected float64, field *data.Field, index int) { - v := field.At(index).(*float64) - require.Equal(t, expected, *v, fmt.Sprintf("wrong flaot at index %v", index)) -} - -func requireTimeSeriesName(t *testing.T, expected string, frame *data.Frame) { - getField := func() *data.Field { - for _, field := range frame.Fields { - if field.Type() != data.FieldTypeTime { - return field - } - } - return nil - } - - field := getField() - require.NotNil(t, expected, field.Config) - require.Equal(t, expected, field.Config.DisplayNameFromDS) -} - -func TestRefIdMatching(t *testing.T) { - require.NoError(t, nil) - query := []byte(` - [ - { - "refId": "COUNT_GROUPBY_DATE_HISTOGRAM", - "metrics": [{ "type": "count", "id": "c_1" }], - "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "c_2" }] - }, - { - "refId": "COUNT_GROUPBY_HISTOGRAM", - "metrics": [{ "type": "count", "id": "h_3" }], - "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "h_4" }] - }, - { - "refId": "RAW_DOC", - "metrics": [{ "type": "raw_document", "id": "r_5" }], - "bucketAggs": [] - }, - { - "refId": "PERCENTILE", - "metrics": [ - { - "type": "percentiles", - "settings": { "percents": ["75", "90"] }, - "id": "p_1" - } - ], - "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "p_3" }] - }, - { - "refId": "EXTENDEDSTATS", - "metrics": [ - { - "type": "extended_stats", - "meta": { "max": true, "std_deviation_bounds_upper": true }, - "id": "e_1" - } - ], - "bucketAggs": [ - { "type": "terms", "field": "host", "id": "e_3" }, - { "type": "date_histogram", "id": "e_4" } - ] - }, - { - "refId": "RAWDATA", - "metrics": [{ "type": "raw_data", "id": "6" }], - "bucketAggs": [] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "c_2": { - "buckets": [{"doc_count": 10, "key": 1000}] - } - } - }, - { - "aggregations": { - "h_4": { - "buckets": [{ "doc_count": 1, "key": 1000 }] - } - } - }, - { - "hits": { - "total": 2, - "hits": [ - { - "_id": "5", - "_type": "type", - "_index": "index", - "_source": { "sourceProp": "asd" }, - "fields": { "fieldProp": "field" } - }, - { - "_source": { "sourceProp": "asd2" }, - "fields": { "fieldProp": "field2" } - } - ] - } - }, - { - "aggregations": { - "p_3": { - "buckets": [ - { - "p_1": { "values": { "75": 3.3, "90": 5.5 } }, - "doc_count": 10, - "key": 1000 - }, - { - "p_1": { "values": { "75": 2.3, "90": 4.5 } }, - "doc_count": 15, - "key": 2000 - } - ] - } - } - }, - { - "aggregations": { - "e_3": { - "buckets": [ - { - "key": "server1", - "e_4": { - "buckets": [ - { - "e_1": { - "max": 10.2, - "min": 5.5, - "std_deviation_bounds": { "upper": 3, "lower": -2 } - }, - "doc_count": 10, - "key": 1000 - } - ] - } - }, - { - "key": "server2", - "e_4": { - "buckets": [ - { - "e_1": { - "max": 10.2, - "min": 5.5, - "std_deviation_bounds": { "upper": 3, "lower": -2 } - }, - "doc_count": 10, - "key": 1000 - } - ] - } - } - ] - } - } - }, - { - "hits": { - "total": { - "relation": "eq", - "value": 1 - }, - "hits": [ - { - "_id": "6", - "_type": "_doc", - "_index": "index", - "_source": { "sourceProp": "asd" } - } - ] - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - verifyFrames := func(name string, expectedLength int) { - r, found := result.response.Responses[name] - require.True(t, found, "not found: "+name) - require.NoError(t, r.Error) - require.Len(t, r.Frames, expectedLength, "length wrong for "+name) - } - - verifyFrames("COUNT_GROUPBY_DATE_HISTOGRAM", 1) - verifyFrames("COUNT_GROUPBY_HISTOGRAM", 1) - verifyFrames("RAW_DOC", 1) - verifyFrames("PERCENTILE", 2) - verifyFrames("EXTENDEDSTATS", 4) - verifyFrames("RAWDATA", 1) -} - -func TestSimpleQueryReturns1Frame(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "2" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { "doc_count": 10, "key": 1000 }, - { "doc_count": 15, "key": 2000 } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1, "frame-count wrong") - frame := frames[0] - requireTimeSeriesName(t, "Count", frame) - - requireFrameLength(t, frame, 2) - requireTimeValue(t, 1000, frame, 0) - requireNumberValue(t, 10, frame, 0) -} - -func TestSimpleQueryCountAndAvg(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "type": "count", "id": "1" }, - { "type": "avg", "field": "value", "id": "2" } - ], - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { "2": { "value": 88 }, "doc_count": 10, "key": 1000 }, - { "2": { "value": 99 }, "doc_count": 15, "key": 2000 } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - - frame1 := frames[0] - frame2 := frames[1] - - requireFrameLength(t, frame1, 2) - requireFrameLength(t, frame2, 2) - - requireTimeValue(t, 1000, frame1, 0) - requireNumberValue(t, 10, frame1, 0) - - requireTimeSeriesName(t, "Average value", frame2) - - requireNumberValue(t, 88, frame2, 0) - requireNumberValue(t, 99, frame2, 1) -} - -func TestSimpleGroupBy1Metric2Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [ - { "type": "terms", "field": "host", "id": "2" }, - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "3": { - "buckets": [ - { "doc_count": 1, "key": 1000 }, - { "doc_count": 3, "key": 2000 } - ] - }, - "doc_count": 4, - "key": "server1" - }, - { - "3": { - "buckets": [ - { "doc_count": 2, "key": 1000 }, - { "doc_count": 8, "key": 2000 } - ] - }, - "doc_count": 10, - "key": "server2" - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "server1", frames[0]) - requireTimeSeriesName(t, "server2", frames[1]) -} - -func TestSimpleGroupBy2Metrics4Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "type": "count", "id": "1" }, - { "type": "avg", "field": "@value", "id": "4" } - ], - "bucketAggs": [ - { "type": "terms", "field": "host", "id": "2" }, - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "3": { - "buckets": [ - { "4": { "value": 10 }, "doc_count": 1, "key": 1000 }, - { "4": { "value": 12 }, "doc_count": 3, "key": 2000 } - ] - }, - "doc_count": 4, - "key": "server1" - }, - { - "3": { - "buckets": [ - { "4": { "value": 20 }, "doc_count": 1, "key": 1000 }, - { "4": { "value": 32 }, "doc_count": 3, "key": 2000 } - ] - }, - "doc_count": 10, - "key": "server2" - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 4) - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "server1 Count", frames[0]) - requireTimeSeriesName(t, "server1 Average @value", frames[1]) - requireTimeSeriesName(t, "server2 Count", frames[2]) - requireTimeSeriesName(t, "server2 Average @value", frames[3]) -} - -func TestPercentiles2Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { - "type": "percentiles", - "settings": { "percents": ["75", "90"] }, - "id": "1", - "field": "@value" - } - ], - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { - "1": { "values": { "75": 3.3, "90": 5.5 } }, - "doc_count": 10, - "key": 1000 - }, - { - "1": { "values": { "75": 2.3, "90": 4.5 } }, - "doc_count": 15, - "key": 2000 - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "p75 @value", frames[0]) - requireTimeSeriesName(t, "p90 @value", frames[1]) - - requireNumberValue(t, 3.3, frames[0], 0) - requireTimeValue(t, 1000, frames[0], 0) - requireNumberValue(t, 4.5, frames[1], 1) -} - -func TestExtendedStats4Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { - "type": "extended_stats", - "meta": { "max": true, "std_deviation_bounds_upper": true }, - "id": "1", - "field": "@value" - } - ], - "bucketAggs": [ - { "type": "terms", "field": "host", "id": "3" }, - { "type": "date_histogram", "id": "4" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { - "4": { - "buckets": [ - { - "1": { - "max": 10.2, - "min": 5.5, - "std_deviation_bounds": { "upper": 3, "lower": -2 } - }, - "doc_count": 10, - "key": 1000 - } - ] - }, - "key": "server1" - }, - { - "4": { - "buckets": [ - { - "1": { - "max": 10.2, - "min": 5.5, - "std_deviation_bounds": { "upper": 3, "lower": -2 } - }, - "doc_count": 10, - "key": 1000 - } - ] - }, - "key": "server2" - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 4) - requireFrameLength(t, frames[0], 1) - requireTimeSeriesName(t, "server1 Max @value", frames[0]) - requireTimeSeriesName(t, "server1 Std Dev Upper @value", frames[1]) - - requireNumberValue(t, 10.2, frames[0], 0) - requireNumberValue(t, 3, frames[1], 0) -} - -func TestTopMetrics2Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { - "type": "top_metrics", - "settings": { - "order": "top", - "orderBy": "@timestamp", - "metrics": ["@value", "@anotherValue"] - }, - "id": "1" - } - ], - "bucketAggs": [{ "type": "date_histogram", "id": "2" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { - "top": [ - { - "sort": ["2021-01-01T00:00:00.000Z"], - "metrics": { "@value": 1, "@anotherValue": 2 } - } - ] - }, - "key": 1609459200000, - "key_as_string": "2021-01-01T00:00:00.000Z" - }, - { - "1": { - "top": [ - { - "sort": ["2021-01-01T00:00:10.000Z"], - "metrics": { "@value": 1, "@anotherValue": 2 } - } - ] - }, - "key": 1609459210000, - "key_as_string": "2021-01-01T00:00:10.000Z" - } - ] - } - } - } - ] - } - `) - - time1, err := time.Parse(time.RFC3339, "2021-01-01T00:00:00.000Z") - require.NoError(t, err) - time2, err := time.Parse(time.RFC3339, "2021-01-01T00:00:10.000Z") - require.NoError(t, err) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - - frame1 := frames[0] - frame2 := frames[1] - - requireTimeSeriesName(t, "Top Metrics @value", frame1) - requireFrameLength(t, frame1, 2) - requireTimeValue(t, time1.UTC().UnixMilli(), frame1, 0) - requireTimeValue(t, time2.UTC().UnixMilli(), frame1, 1) - requireNumberValue(t, 1, frame1, 0) - requireNumberValue(t, 1, frame1, 1) - - requireTimeSeriesName(t, "Top Metrics @anotherValue", frame2) - requireFrameLength(t, frame2, 2) - requireTimeValue(t, time1.UTC().UnixMilli(), frame2, 0) - requireTimeValue(t, time2.UTC().UnixMilli(), frame2, 1) - requireNumberValue(t, 2, frame2, 0) - requireNumberValue(t, 2, frame2, 1) -} - -func TestSingleGroupWithAliasPattern3Frames(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "count", "id": "1" }], - "alias": "{{term @host}} {{metric}} and {{not_exist}} {{@host}}", - "bucketAggs": [ - { "type": "terms", "field": "@host", "id": "2" }, - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "3": { - "buckets": [ - { "doc_count": 1, "key": 1000 }, - { "doc_count": 3, "key": 2000 } - ] - }, - "doc_count": 4, - "key": "server1" - }, - { - "3": { - "buckets": [ - { "doc_count": 2, "key": 1000 }, - { "doc_count": 8, "key": 2000 } - ] - }, - "doc_count": 10, - "key": "server2" - }, - { - "3": { - "buckets": [ - { "doc_count": 2, "key": 1000 }, - { "doc_count": 8, "key": 2000 } - ] - }, - "doc_count": 10, - "key": 0 - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 3) - - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "server1 Count and {{not_exist}} server1", frames[0]) - requireTimeSeriesName(t, "server2 Count and {{not_exist}} server2", frames[1]) - requireTimeSeriesName(t, "0 Count and {{not_exist}} 0", frames[2]) -} - -func TestHistogramSimple(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "3" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { "doc_count": 1, "key": 1000 }, - { "doc_count": 3, "key": 2000 }, - { "doc_count": 2, "key": 1000 } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) - requireFrameLength(t, frames[0], 3) - - fields := frames[0].Fields - require.Len(t, fields, 2) - - field1 := fields[0] - field2 := fields[1] - - require.Equal(t, "bytes", field1.Name) - - trueValue := true - filterableConfig := data.FieldConfig{Filterable: &trueValue} - - // we need to test that the only changed setting is `filterable` - require.Equal(t, filterableConfig, *field1.Config) - require.Equal(t, "Count", field2.Name) - // we need to test that the fieldConfig is "empty" - require.Nil(t, field2.Config) -} - -func TestHistogramWith2FiltersAgg(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [ - { - "id": "2", - "type": "filters", - "settings": { - "filters": [ - { "query": "@metric:cpu", "label": "" }, - { "query": "@metric:logins.count", "label": "" } - ] - } - }, - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": { - "@metric:cpu": { - "3": { - "buckets": [ - { "doc_count": 1, "key": 1000 }, - { "doc_count": 3, "key": 2000 } - ] - } - }, - "@metric:logins.count": { - "3": { - "buckets": [ - { "doc_count": 2, "key": 1000 }, - { "doc_count": 8, "key": 2000 } - ] - } - } - } - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "@metric:cpu", frames[0]) - requireTimeSeriesName(t, "@metric:logins.count", frames[1]) -} - -func TestTrimEdges(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "type": "avg", "id": "1", "field": "@value" }, - { "type": "count", "id": "3" } - ], - "bucketAggs": [ - { - "id": "2", - "type": "date_histogram", - "field": "host", - "settings": { "trimEdges": "1" } - } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { "1": { "value": 1000 }, "key": 1, "doc_count": 369 }, - { "1": { "value": 2000 }, "key": 2, "doc_count": 200 }, - { "1": { "value": 2000 }, "key": 3, "doc_count": 200 } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 2) - - // should remove first and last value - requireFrameLength(t, frames[0], 1) -} - -func TestTermsAggWithoutDateHistogram(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "type": "avg", "id": "1", "field": "@value" }, - { "type": "count", "id": "3" } - ], - "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { "1": { "value": 1000 }, "key": "server-1", "doc_count": 369 }, - { "1": { "value": 2000 }, "key": "server-2", "doc_count": 200 } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) - - frame1 := frames[0] - requireFrameLength(t, frame1, 2) - require.Len(t, frame1.Fields, 3) - - f1 := frame1.Fields[0] - f2 := frame1.Fields[1] - f3 := frame1.Fields[2] - - requireStringAt(t, "server-1", f1, 0) - requireStringAt(t, "server-2", f1, 1) - - requireFloatAt(t, 1000.0, f2, 0) - requireFloatAt(t, 2000.0, f2, 1) - - requireFloatAt(t, 369.0, f3, 0) - requireFloatAt(t, 200.0, f3, 1) -} - -func TestPercentilesWithoutDateHistogram(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { - "type": "percentiles", - "field": "value", - "settings": { "percents": ["75", "90"] }, - "id": "1" - } - ], - "bucketAggs": [{ "type": "terms", "field": "id", "id": "3" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { - "1": { "values": { "90": 5.5, "75": 3.3 } }, - "doc_count": 10, - "key": "id1" - }, - { - "1": { "values": { "75": 2.3, "90": 4.5 } }, - "doc_count": 15, - "key": "id2" - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) - requireFrameLength(t, frames[0], 2) - - require.Len(t, frames[0].Fields, 3) - - f1 := frames[0].Fields[0] - f2 := frames[0].Fields[1] - f3 := frames[0].Fields[2] - - require.Equal(t, "id", f1.Name) - require.Equal(t, "p75 value", f2.Name) - require.Equal(t, "p90 value", f3.Name) - - requireStringAt(t, "id1", f1, 0) - requireStringAt(t, "id2", f1, 1) - - requireFloatAt(t, 3.3, f2, 0) - requireFloatAt(t, 2.3, f2, 1) - - requireFloatAt(t, 5.5, f3, 0) - requireFloatAt(t, 4.5, f3, 1) -} - -func TestMultipleMetricsOfTheSameType(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "type": "avg", "id": "1", "field": "test" }, - { "type": "avg", "id": "2", "field": "test2" } - ], - "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 1000 }, - "2": { "value": 3000 }, - "key": "server-1", - "doc_count": 369 - } - ] - } - } - } - ] - } - - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.True(t, len(frames) > 0) - requireFrameLength(t, frames[0], 1) - require.Len(t, frames[0].Fields, 3) - - requireStringAt(t, "server-1", frames[0].Fields[0], 0) - requireFloatAt(t, 1000.0, frames[0].Fields[1], 0) - requireFloatAt(t, 3000.0, frames[0].Fields[2], 0) -} - -func TestRawDocumentQuery(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "raw_document", "id": "1" }], - "bucketAggs": [] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "hits": { - "total": 100, - "hits": [ - { - "_id": "1", - "_type": "type", - "_index": "index", - "_source": { "sourceProp": "asd" }, - "fields": { "fieldProp": "field" } - }, - { - "_source": { "sourceProp": "asd2" }, - "fields": { "fieldProp": "field2" } - } - ] - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) - fields := frames[0].Fields - - require.Len(t, fields, 1) - f := fields[0] - - require.Equal(t, data.FieldTypeNullableJSON, f.Type()) - require.Equal(t, 2, f.Len()) - - v := f.At(0).(*json.RawMessage) - var jsonData map[string]interface{} - err = json.Unmarshal(*v, &jsonData) - require.NoError(t, err) - - require.Equal(t, "asd", jsonData["sourceProp"]) - require.Equal(t, "field", jsonData["fieldProp"]) -} - -func TestBucketScript(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "id": "1", "type": "sum", "field": "@value" }, - { "id": "3", "type": "max", "field": "@value" }, - { - "id": "4", - "pipelineVariables": [ - { "name": "var1", "pipelineAgg": "1" }, - { "name": "var2", "pipelineAgg": "3" } - ], - "settings": { "script": "params.var1 * params.var2" }, - "type": "bucket_script" - } - ], - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "2" } - ] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 2 }, - "3": { "value": 3 }, - "4": { "value": 6 }, - "doc_count": 60, - "key": 1000 - }, - { - "1": { "value": 3 }, - "3": { "value": 4 }, - "4": { "value": 12 }, - "doc_count": 60, - "key": 2000 - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 3) - requireFrameLength(t, frames[0], 2) - requireTimeSeriesName(t, "Sum @value", frames[0]) - requireTimeSeriesName(t, "Max @value", frames[1]) - requireTimeSeriesName(t, "Sum @value * Max @value", frames[2]) - - requireNumberValue(t, 2, frames[0], 0) - requireNumberValue(t, 3, frames[1], 0) - requireNumberValue(t, 6, frames[2], 0) - - requireNumberValue(t, 3, frames[0], 1) - requireNumberValue(t, 4, frames[1], 1) - requireNumberValue(t, 12, frames[2], 1) -} - -func TestTwoBucketScripts(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [ - { "id": "1", "type": "sum", "field": "@value" }, - { "id": "3", "type": "max", "field": "@value" }, - { - "id": "4", - "pipelineVariables": [ - { "name": "var1", "pipelineAgg": "1" }, - { "name": "var2", "pipelineAgg": "3" } - ], - "settings": { "script": "params.var1 * params.var2" }, - "type": "bucket_script" - }, - { - "id": "5", - "pipelineVariables": [ - { "name": "var1", "pipelineAgg": "1" }, - { "name": "var2", "pipelineAgg": "3" } - ], - "settings": { "script": "params.var1 * params.var2 * 4" }, - "type": "bucket_script" - } - ], - "bucketAggs": [{ "type": "terms", "field": "@timestamp", "id": "2" }] - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 2 }, - "3": { "value": 3 }, - "4": { "value": 6 }, - "5": { "value": 24 }, - "doc_count": 60, - "key": 1000 - }, - { - "1": { "value": 3 }, - "3": { "value": 4 }, - "4": { "value": 12 }, - "5": { "value": 48 }, - "doc_count": 60, - "key": 2000 - } - ] - } - } - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.True(t, len(frames) > 0) - requireFrameLength(t, frames[0], 2) - - fields := frames[0].Fields - require.Len(t, fields, 5) - - requireFloatAt(t, 1000.0, fields[0], 0) - requireFloatAt(t, 2000.0, fields[0], 1) - requireFloatAt(t, 2.0, fields[1], 0) - requireFloatAt(t, 3.0, fields[1], 1) - requireFloatAt(t, 3.0, fields[2], 0) - requireFloatAt(t, 4.0, fields[2], 1) - requireFloatAt(t, 6.0, fields[3], 0) - requireFloatAt(t, 12.0, fields[3], 1) - requireFloatAt(t, 24.0, fields[4], 0) - requireFloatAt(t, 48.0, fields[4], 1) -} - -func TestLogs(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "logs"}], - "bucketAggs": [ - { - "type": "date_histogram", - "settings": { "interval": "auto" }, - "id": "2" - } - ], - "key": "Q-1561369883389-0.7611823271062786-0", - "query": "hello AND message" - } - ] -`) - - response := []byte(` - { - "responses": [ - { - "aggregations": {}, - "hits": { - "hits": [ - { - "_id": "fdsfs", - "_type": "_doc", - "_index": "mock-index", - "_source": { - "testtime": "2019-06-24T09:51:19.765Z", - "host": "djisaodjsoad", - "number": 1, - "line": "hello, i am a message", - "level": "debug", - "fields": { "lvl": "debug" } - }, - "highlight": { - "message": [ - "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" - ] - } - }, - { - "_id": "kdospaidopa", - "_type": "_doc", - "_index": "mock-index", - "_source": { - "testtime": "2019-06-24T09:52:19.765Z", - "host": "dsalkdakdop", - "number": 2, - "line": "hello, i am also message", - "level": "error", - "fields": { "lvl": "info" } - }, - "highlight": { - "message": [ - "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" - ] - } - } - ] - } - } - ] - } -`) - - t.Run("response", func(t *testing.T) { - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) - - logsFrame := frames[0] - - meta := logsFrame.Meta - require.Equal(t, map[string]interface{}{"searchWords": []string{"hello", "message"}}, meta.Custom) - require.Equal(t, data.VisTypeLogs, string(meta.PreferredVisualization)) - - logsFieldMap := make(map[string]*data.Field) - for _, field := range logsFrame.Fields { - logsFieldMap[field.Name] = field - } - - require.Contains(t, logsFieldMap, "testtime") - require.Equal(t, data.FieldTypeNullableTime, logsFieldMap["testtime"].Type()) - - require.Contains(t, logsFieldMap, "host") - require.Equal(t, data.FieldTypeNullableString, logsFieldMap["host"].Type()) - - require.Contains(t, logsFieldMap, "line") - require.Equal(t, data.FieldTypeNullableString, logsFieldMap["line"].Type()) - - require.Contains(t, logsFieldMap, "number") - require.Equal(t, data.FieldTypeNullableFloat64, logsFieldMap["number"].Type()) - - require.Contains(t, logsFieldMap, "_source") - require.Equal(t, data.FieldTypeNullableJSON, logsFieldMap["_source"].Type()) - - requireStringAt(t, "fdsfs", logsFieldMap["_id"], 0) - requireStringAt(t, "kdospaidopa", logsFieldMap["_id"], 1) - requireStringAt(t, "_doc", logsFieldMap["_type"], 0) - requireStringAt(t, "_doc", logsFieldMap["_type"], 1) - requireStringAt(t, "mock-index", logsFieldMap["_index"], 0) - requireStringAt(t, "mock-index", logsFieldMap["_index"], 1) - - actualJson1, err := json.Marshal(logsFieldMap["_source"].At(0).(*json.RawMessage)) - require.NoError(t, err) - actualJson2, err := json.Marshal(logsFieldMap["_source"].At(1).(*json.RawMessage)) - require.NoError(t, err) - - expectedJson1 := ` - { - "fields.lvl": "debug", - "host": "djisaodjsoad", - "level": "debug", - "line": "hello, i am a message", - "number": 1, - "testtime": "2019-06-24T09:51:19.765Z", - "line": "hello, i am a message" - } - ` - - expectedJson2 := ` - { - "testtime": "2019-06-24T09:52:19.765Z", - "host": "dsalkdakdop", - "number": 2, - "line": "hello, i am also message", - "level": "error", - "fields.lvl": "info" - }` - - require.JSONEq(t, expectedJson1, string(actualJson1)) - require.JSONEq(t, expectedJson2, string(actualJson2)) - }) - - t.Run("level field", func(t *testing.T) { - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.True(t, len(frames) > 0) - - requireFrameLength(t, frames[0], 2) - fieldMap := make(map[string]*data.Field) - for _, field := range frames[0].Fields { - fieldMap[field.Name] = field - } - - require.Contains(t, fieldMap, "level") - field := fieldMap["level"] - - requireStringAt(t, "debug", field, 0) - requireStringAt(t, "error", field, 1) - }) -} - -func TestLogsEmptyResponse(t *testing.T) { - query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "logs", "id": "2" }], - "bucketAggs": [], - "key": "Q-1561369883389-0.7611823271062786-0", - "query": "hello AND message" - } - ] - `) - - response := []byte(` - { - "responses": [ - { - "hits": { "hits": [] }, - "aggregations": {}, - "status": 200 - } - ] - } - `) - - result, err := queryDataTest(query, response) - require.NoError(t, err) - - require.Len(t, result.response.Responses, 1) - frames := result.response.Responses["A"].Frames - require.Len(t, frames, 1) -} diff --git a/pkg/tsdb/elasticsearch/response_parser_test.go b/pkg/tsdb/elasticsearch/response_parser_test.go index fb186ac1f64..770e42e4b2c 100644 --- a/pkg/tsdb/elasticsearch/response_parser_test.go +++ b/pkg/tsdb/elasticsearch/response_parser_test.go @@ -18,58 +18,842 @@ import ( var update = flag.Bool("update", true, "update golden files") -func TestResponseParser(t *testing.T) { - t.Run("Elasticsearch response parser test", func(t *testing.T) { - t.Run("Simple query and count", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "2" }] - }`, - } - response := `{ - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "doc_count": 10, - "key": 1000 - }, - { - "doc_count": 15, - "key": 2000 - } - ] - } - } - } - ] - }` - result, err := parseTestResponse(targets, response) +func TestProcessLogsResponse(t *testing.T) { + t.Run("Simple log query response", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "logs"}], + "bucketAggs": [ + { + "type": "date_histogram", + "settings": { "interval": "auto" }, + "id": "2" + } + ], + "key": "Q-1561369883389-0.7611823271062786-0", + "query": "hello AND message" + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": {}, + "hits": { + "hits": [ + { + "_id": "fdsfs", + "_type": "_doc", + "_index": "mock-index", + "_source": { + "testtime": "2019-06-24T09:51:19.765Z", + "host": "djisaodjsoad", + "number": 1, + "line": "hello, i am a message", + "level": "debug", + "fields": { "lvl": "debug" } + }, + "highlight": { + "message": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ] + } + }, + { + "_id": "kdospaidopa", + "_type": "_doc", + "_index": "mock-index", + "_source": { + "testtime": "2019-06-24T09:52:19.765Z", + "host": "dsalkdakdop", + "number": 2, + "line": "hello, i am also message", + "level": "error", + "fields": { "lvl": "info" } + }, + "highlight": { + "message": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ] + } + } + ] + } + } + ] + } + `) + + t.Run("creates correct data frame fields", func(t *testing.T) { + result, err := queryDataTest(query, response) require.NoError(t, err) - require.Len(t, result.Responses, 1) - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.Len(t, dataframes, 1) + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) - frame := dataframes[0] - require.Len(t, frame.Fields, 2) + logsFrame := frames[0] - require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) - require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + meta := logsFrame.Meta + require.Equal(t, map[string]interface{}{"searchWords": []string{"hello", "message"}}, meta.Custom) + require.Equal(t, data.VisTypeLogs, string(meta.PreferredVisualization)) + + logsFieldMap := make(map[string]*data.Field) + for _, field := range logsFrame.Fields { + logsFieldMap[field.Name] = field + } + + require.Contains(t, logsFieldMap, "testtime") + require.Equal(t, data.FieldTypeNullableTime, logsFieldMap["testtime"].Type()) + + require.Contains(t, logsFieldMap, "host") + require.Equal(t, data.FieldTypeNullableString, logsFieldMap["host"].Type()) + + require.Contains(t, logsFieldMap, "line") + require.Equal(t, data.FieldTypeNullableString, logsFieldMap["line"].Type()) + + require.Contains(t, logsFieldMap, "number") + require.Equal(t, data.FieldTypeNullableFloat64, logsFieldMap["number"].Type()) + + require.Contains(t, logsFieldMap, "_source") + require.Equal(t, data.FieldTypeNullableJSON, logsFieldMap["_source"].Type()) + + requireStringAt(t, "fdsfs", logsFieldMap["_id"], 0) + requireStringAt(t, "kdospaidopa", logsFieldMap["_id"], 1) + requireStringAt(t, "_doc", logsFieldMap["_type"], 0) + requireStringAt(t, "_doc", logsFieldMap["_type"], 1) + requireStringAt(t, "mock-index", logsFieldMap["_index"], 0) + requireStringAt(t, "mock-index", logsFieldMap["_index"], 1) + + actualJson1, err := json.Marshal(logsFieldMap["_source"].At(0).(*json.RawMessage)) + require.NoError(t, err) + actualJson2, err := json.Marshal(logsFieldMap["_source"].At(1).(*json.RawMessage)) + require.NoError(t, err) + + expectedJson1 := ` + { + "fields.lvl": "debug", + "host": "djisaodjsoad", + "level": "debug", + "line": "hello, i am a message", + "number": 1, + "testtime": "2019-06-24T09:51:19.765Z", + "line": "hello, i am a message" + } + ` + + expectedJson2 := ` + { + "testtime": "2019-06-24T09:52:19.765Z", + "host": "dsalkdakdop", + "number": 2, + "line": "hello, i am also message", + "level": "error", + "fields.lvl": "info" + }` + + require.JSONEq(t, expectedJson1, string(actualJson1)) + require.JSONEq(t, expectedJson2, string(actualJson2)) }) - t.Run("Simple query count & avg aggregation", func(t *testing.T) { + t.Run("creates correct level field", func(t *testing.T) { + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + + requireFrameLength(t, frames[0], 2) + fieldMap := make(map[string]*data.Field) + for _, field := range frames[0].Fields { + fieldMap[field.Name] = field + } + + require.Contains(t, fieldMap, "level") + field := fieldMap["level"] + + requireStringAt(t, "debug", field, 0) + requireStringAt(t, "error", field, 1) + }) + }) + t.Run("Empty response", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "logs", "id": "2" }], + "bucketAggs": [], + "key": "Q-1561369883389-0.7611823271062786-0", + "query": "hello AND message" + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { "hits": [] }, + "aggregations": {}, + "status": 200 + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + }) + t.Run("Log query with nested fields", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "logs" }] + }`, + } + + response := `{ + "responses":[ + { + "hits":{ + "total":{ + "value":109, + "relation":"eq" + }, + "max_score":null, + "hits":[ + { + "_index":"logs-2023.02.08", + "_id":"GB2UMYYBfCQ-FCMjayJa", + "_score":null, + "_source":{ + "@timestamp":"2023-02-08T15:10:55.830Z", + "line":"log text [479231733]", + "counter":"109", + "float":58.253758485091, + "label":"val1", + "lvl":"info", + "location":"17.089705232090438, 41.62861966340297", + "nested": { + "field": { + "double_nested": "value" + } + }, + "shapes":[ + { + "type":"triangle" + }, + { + "type":"square" + } + ], + "xyz": null + }, + "sort":[ + 1675869055830, + 4 + ] + }, + { + "_index":"logs-2023.02.08", + "_id":"Fx2UMYYBfCQ-FCMjZyJ_", + "_score":null, + "_source":{ + "@timestamp":"2023-02-08T15:10:54.835Z", + "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", + "counter":"108", + "float":54.5977098233944, + "label":"val1", + "lvl":"info", + "location":"19.766305918490463, 40.42639175509792", + "nested": { + "field": { + "double_nested": "value" + } + }, + "shapes":[ + { + "type":"triangle" + }, + { + "type":"square" + } + ], + "xyz": "def" + }, + "sort":[ + 1675869054835, + 7 + ] + } + ] + }, + "status":200 + } + ] + }` + + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.Len(t, dataframes, 1) + frame := dataframes[0] + + require.Equal(t, 16, len(frame.Fields)) + // Fields have the correct length + require.Equal(t, 2, frame.Fields[0].Len()) + // First field is timeField + require.Equal(t, data.FieldTypeNullableTime, frame.Fields[0].Type()) + // Second is log line + require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) + require.Equal(t, "line", frame.Fields[1].Name) + // Correctly renames lvl field to level + require.Equal(t, "level", frame.Fields[10].Name) + // Correctly uses string types + require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) + // Correctly detects float64 types + require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[7].Type()) + // Correctly detects json types + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[8].Type()) + // Correctly flattens fields + require.Equal(t, "nested.field.double_nested", frame.Fields[12].Name) + require.Equal(t, data.FieldTypeNullableString, frame.Fields[12].Type()) + // Correctly detects type even if first value is null + require.Equal(t, data.FieldTypeNullableString, frame.Fields[15].Type()) + }) + + t.Run("Log query with highlight", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "logs" }] + }`, + } + + response := `{ + "responses":[ + { + "hits":{ + "total":{ + "value":109, + "relation":"eq" + }, + "max_score":null, + "hits":[ + { + "_index":"logs-2023.02.08", + "_id":"GB2UMYYBfCQ-FCMjayJa", + "_score":null, + "highlight": { + "line": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ], + "duplicated": ["@HIGHLIGHT@hello@/HIGHLIGHT@"] + }, + "_source":{ + "@timestamp":"2023-02-08T15:10:55.830Z", + "line":"log text [479231733]" + } + }, + { + "_index":"logs-2023.02.08", + "_id":"GB2UMYYBfCQ-FCMjayJa", + "_score":null, + "highlight": { + "line": [ + "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" + ], + "duplicated": ["@HIGHLIGHT@hello@/HIGHLIGHT@"] + }, + "_source":{ + "@timestamp":"2023-02-08T15:10:55.830Z", + "line":"log text [479231733]" + } + } + ] + }, + "status":200 + } + ] + }` + + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.Len(t, dataframes, 1) + frame := dataframes[0] + + customMeta := frame.Meta.Custom + + require.Equal(t, map[string]interface{}{ + "searchWords": []string{"hello", "message"}, + }, customMeta) + }) +} + +func TestProcessRawDataResponse(t *testing.T) { + t.Run("Simple raw data query", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "raw_data" }] + }`, + } + + response := `{ + "responses":[ + { + "hits":{ + "total":{ + "value":109, + "relation":"eq" + }, + "max_score":null, + "hits":[ + { + "_index":"logs-2023.02.08", + "_id":"GB2UMYYBfCQ-FCMjayJa", + "_score":null, + "_source":{ + "@timestamp":"2023-02-08T15:10:55.830Z", + "line":"log text [479231733]", + "counter":"109", + "float":58.253758485091, + "label":"val1", + "level":"info", + "location":"17.089705232090438, 41.62861966340297", + "nested": { + "field": { + "double_nested": "value" + } + }, + "shapes":[ + { + "type":"triangle" + }, + { + "type":"square" + } + ], + "xyz": null + }, + "sort":[ + 1675869055830, + 4 + ] + }, + { + "_index":"logs-2023.02.08", + "_id":"Fx2UMYYBfCQ-FCMjZyJ_", + "_score":null, + "_source":{ + "@timestamp":"2023-02-08T15:10:54.835Z", + "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", + "counter":"108", + "float":54.5977098233944, + "label":"val1", + "level":"info", + "location":"19.766305918490463, 40.42639175509792", + "nested": { + "field": { + "double_nested": "value" + } + }, + "shapes":[ + { + "type":"triangle" + }, + { + "type":"square" + } + ], + "xyz": "def" + }, + "sort":[ + 1675869054835, + 7 + ] + } + ] + }, + "status":200 + } + ] + }` + + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.Len(t, dataframes, 1) + frame := dataframes[0] + + require.Equal(t, 15, len(frame.Fields)) + // Fields have the correct length + require.Equal(t, 2, frame.Fields[0].Len()) + // First field is timeField + require.Equal(t, data.FieldTypeNullableTime, frame.Fields[0].Type()) + // Correctly uses string types + require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) + // Correctly detects float64 types + require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[5].Type()) + // Correctly detects json types + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[6].Type()) + // Correctly flattens fields + require.Equal(t, "nested.field.double_nested", frame.Fields[11].Name) + require.Equal(t, data.FieldTypeNullableString, frame.Fields[11].Type()) + // Correctly detects type even if first value is null + require.Equal(t, data.FieldTypeNullableString, frame.Fields[14].Type()) + }) + + t.Run("Raw data query filterable fields", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "raw_data", "id": "1" }], + "bucketAggs": [] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { + "total": { "relation": "eq", "value": 1 }, + "hits": [ + { + "_id": "1", + "_type": "_doc", + "_index": "index", + "_source": { "sourceProp": "asd" } + } + ] + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + + for _, field := range frames[0].Fields { + trueValue := true + filterableConfig := data.FieldConfig{Filterable: &trueValue} + + // we need to test that the only changed setting is `filterable` + require.Equal(t, filterableConfig, *field.Config) + } + }) +} + +func TestProcessRawDocumentResponse(t *testing.T) { + t.Run("Simple raw document query", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "raw_document", "id": "1" }], + "bucketAggs": [] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "hits": { + "total": 100, + "hits": [ + { + "_id": "1", + "_type": "type", + "_index": "index", + "_source": { "sourceProp": "asd" }, + "fields": { "fieldProp": "field" } + }, + { + "_source": { "sourceProp": "asd2" }, + "fields": { "fieldProp": "field2" } + } + ] + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + fields := frames[0].Fields + + require.Len(t, fields, 1) + f := fields[0] + + require.Equal(t, data.FieldTypeNullableJSON, f.Type()) + require.Equal(t, 2, f.Len()) + + v := f.At(0).(*json.RawMessage) + var jsonData map[string]interface{} + err = json.Unmarshal(*v, &jsonData) + require.NoError(t, err) + + require.Equal(t, "asd", jsonData["sourceProp"]) + require.Equal(t, "field", jsonData["fieldProp"]) + }) + t.Run("More complex raw document query", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "raw_document" }] + }`, + } + + response := `{ + "responses":[ + { + "hits":{ + "total":{ + "value":109, + "relation":"eq" + }, + "max_score":null, + "hits":[ + { + "_index":"logs-2023.02.08", + "_id":"GB2UMYYBfCQ-FCMjayJa", + "_score":null, + "fields": { + "test_field":"A" + }, + "_source":{ + "@timestamp":"2023-02-08T15:10:55.830Z", + "line":"log text [479231733]", + "counter":"109", + "float":58.253758485091, + "label":"val1", + "level":"info", + "location":"17.089705232090438, 41.62861966340297", + "nested": { + "field": { + "double_nested": "value" + } + } + } + }, + { + "_index":"logs-2023.02.08", + "_id":"Fx2UMYYBfCQ-FCMjZyJ_", + "_score":null, + "fields": { + "test_field":"A" + }, + "_source":{ + "@timestamp":"2023-02-08T15:10:54.835Z", + "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", + "counter":"108", + "float":54.5977098233944, + "label":"val1", + "level":"info", + "location":"19.766305918490463, 40.42639175509792", + "nested": { + "field": { + "double_nested": "value1" + } + } + } + } + ] + }, + "status":200 + } + ] + }` + + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.Len(t, dataframes, 1) + frame := dataframes[0] + + require.Equal(t, 1, len(frame.Fields)) + //Fields have the correct length + require.Equal(t, 2, frame.Fields[0].Len()) + // The only field is the raw document + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[0].Type()) + require.Equal(t, "A", frame.Fields[0].Name) + }) +} + +func TestProcessBuckets(t *testing.T) { + t.Run("Percentiles", func(t *testing.T) { + t.Run("Percentiles without date histogram", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { + "type": "percentiles", + "field": "value", + "settings": { "percents": ["75", "90"] }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "terms", "field": "id", "id": "3" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "1": { "values": { "90": 5.5, "75": 3.3 } }, + "doc_count": 10, + "key": "id1" + }, + { + "1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": "id2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + requireFrameLength(t, frames[0], 2) + + require.Len(t, frames[0].Fields, 3) + + f1 := frames[0].Fields[0] + f2 := frames[0].Fields[1] + f3 := frames[0].Fields[2] + + require.Equal(t, "id", f1.Name) + require.Equal(t, "p75 value", f2.Name) + require.Equal(t, "p90 value", f3.Name) + + requireStringAt(t, "id1", f1, 0) + requireStringAt(t, "id2", f1, 1) + + requireFloatAt(t, 3.3, f2, 0) + requireFloatAt(t, 2.3, f2, 1) + + requireFloatAt(t, 5.5, f3, 0) + requireFloatAt(t, 4.5, f3, 1) + }) + t.Run("percentiles 2 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { + "type": "percentiles", + "settings": { "percents": ["75", "90"] }, + "id": "1", + "field": "@value" + } + ], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "1": { "values": { "75": 3.3, "90": 5.5 } }, + "doc_count": 10, + "key": 1000 + }, + { + "1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": 2000 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "p75 @value", frames[0]) + requireTimeSeriesName(t, "p90 @value", frames[1]) + + requireNumberValue(t, 3.3, frames[0], 0) + requireTimeValue(t, 1000, frames[0], 0) + requireNumberValue(t, 4.5, frames[1], 1) + }) + + t.Run("With percentiles", func(t *testing.T) { targets := map[string]string{ "A": `{ - "metrics": [{ "type": "count", "id": "1" }, {"type": "avg", "field": "value", "id": "2" }], + "metrics": [{ "type": "percentiles", "settings": { "percents": [75, 90] }, "id": "1" }], "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "3" }] }`, } @@ -80,12 +864,12 @@ func TestResponseParser(t *testing.T) { "3": { "buckets": [ { - "2": { "value": 88 }, + "1": { "values": { "75": 3.3, "90": 5.5 } }, "doc_count": 10, "key": 1000 }, { - "2": { "value": 99 }, + "1": { "values": { "75": 2.3, "90": 4.5 } }, "doc_count": 15, "key": 2000 } @@ -107,52 +891,94 @@ func TestResponseParser(t *testing.T) { frame := dataframes[0] require.Len(t, frame.Fields, 2) - require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p75") frame = dataframes[1] require.Len(t, frame.Fields, 2) - require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Average value") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p90") + }) + }) + + t.Run("Histograms", func(t *testing.T) { + t.Run("Histogram simple", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "3" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 }, + { "doc_count": 2, "key": 1000 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + requireFrameLength(t, frames[0], 3) + + fields := frames[0].Fields + require.Len(t, fields, 2) + + field1 := fields[0] + field2 := fields[1] + + require.Equal(t, "bytes", field1.Name) + + trueValue := true + filterableConfig := data.FieldConfig{Filterable: &trueValue} + + // we need to test that the only changed setting is `filterable` + require.Equal(t, filterableConfig, *field1.Config) + require.Equal(t, "Count", field2.Name) + // we need to test that the fieldConfig is "empty" + require.Nil(t, field2.Config) }) - t.Run("Query with duplicated avg metric creates unique field name", func(t *testing.T) { + t.Run("Histogram response", func(t *testing.T) { targets := map[string]string{ "A": `{ - "metrics": [{"type": "avg", "field": "value", "id": "1" }, {"type": "avg", "field": "value", "id": "4" }], - "bucketAggs": [{ "type": "terms", "field": "label", "id": "3" }] + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "3" }] }`, } response := `{ "responses": [ - { - "aggregations": { - "3": { - "buckets": [ - { - "1": { "value": 88 }, - "4": { "value": 88 }, - "doc_count": 10, - "key": "val1" - }, - { - "1": { "value": 99 }, - "4": { "value": 99 }, - "doc_count": 15, - "key": "val2" - } - ] - } - } - } + { + "aggregations": { + "3": { + "buckets": [{ "doc_count": 1, "key": 1000 }, { "doc_count": 3, "key": 2000 }, { "doc_count": 2, "key": 3000 }] + } + } + } ] }` result, err := parseTestResponse(targets, response) @@ -164,12 +990,611 @@ func TestResponseParser(t *testing.T) { dataframes := queryRes.Frames require.NoError(t, err) require.Len(t, dataframes, 1) + }) + }) + + t.Run("Terms", func(t *testing.T) { + t.Run("Terms with two bucket_script", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [ + { "id": "1", "type": "sum", "field": "@value" }, + { "id": "3", "type": "max", "field": "@value" }, + { + "id": "4", + "pipelineVariables": [{ "name": "var1", "pipelineAgg": "1" }, { "name": "var2", "pipelineAgg": "3" }], + "settings": { "script": "params.var1 * params.var2" }, + "type": "bucket_script" + }, + { + "id": "5", + "pipelineVariables": [{ "name": "var1", "pipelineAgg": "1" }, { "name": "var2", "pipelineAgg": "3" }], + "settings": { "script": "params.var1 * params.var2 * 2" }, + "type": "bucket_script" + } + ], + "bucketAggs": [{ "type": "terms", "field": "@timestamp", "id": "2" }] + }`, + } + response := `{ + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 2 }, + "3": { "value": 3 }, + "4": { "value": 6 }, + "5": { "value": 24 }, + "doc_count": 60, + "key": 1000 + }, + { + "1": { "value": 3 }, + "3": { "value": 4 }, + "4": { "value": 12 }, + "5": { "value": 48 }, + "doc_count": 60, + "key": 2000 + } + ] + } + } + } + ] + }` + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.NoError(t, err) + require.Len(t, dataframes, 1) frame := dataframes[0] - require.Len(t, frame.Fields, 3) - require.Equal(t, frame.Fields[0].Name, "label") - require.Equal(t, frame.Fields[1].Name, "Average value 1") - require.Equal(t, frame.Fields[2].Name, "Average value 4") + require.Len(t, frame.Fields, 5) + require.Equal(t, frame.Fields[0].Name, "@timestamp") + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, "Sum") + require.Equal(t, frame.Fields[1].Len(), 2) + require.Equal(t, frame.Fields[2].Name, "Max") + require.Equal(t, frame.Fields[2].Len(), 2) + require.Equal(t, frame.Fields[3].Name, "params.var1 * params.var2") + require.Equal(t, frame.Fields[3].Len(), 2) + require.Equal(t, frame.Fields[4].Name, "params.var1 * params.var2 * 2") + require.Equal(t, frame.Fields[4].Len(), 2) + require.Nil(t, frame.Fields[1].Config) + }) + + t.Run("With max and multiple terms agg", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [ + { + "type": "max", + "field": "counter", + "id": "1" + } + ], + "bucketAggs": [{ "type": "terms", "field": "label", "id": "2" }, { "type": "terms", "field": "level", "id": "3" }] + }`, + } + response := `{ + "responses": [{ + "aggregations": { + "2": { + "buckets": [ + { + "key": "val3", + "3": { + "buckets": [ + { "key": "info", "1": { "value": "299" } }, { "key": "error", "1": {"value": "300"} } + ] + } + }, + { + "key": "val2", + "3": { + "buckets": [ + {"key": "info", "1": {"value": "300"}}, {"key": "error", "1": {"value": "298"} } + ] + } + }, + { + "key": "val1", + "3": { + "buckets": [ + {"key": "info", "1": {"value": "299"}}, {"key": "error", "1": {"value": "296"} } + ] + } + } + ] + } + } + }] + }` + + result, err := parseTestResponse(targets, response) + assert.Nil(t, err) + assert.Len(t, result.Responses, 1) + frames := result.Responses["A"].Frames + require.Len(t, frames, 1) + requireFrameLength(t, frames[0], 6) + require.Len(t, frames[0].Fields, 3) + + f1 := frames[0].Fields[0] + f2 := frames[0].Fields[1] + f3 := frames[0].Fields[2] + + require.Equal(t, "label", f1.Name) + require.Equal(t, "level", f2.Name) + require.Equal(t, "Max", f3.Name) + + requireStringAt(t, "val3", f1, 0) + requireStringAt(t, "val3", f1, 1) + requireStringAt(t, "val2", f1, 2) + requireStringAt(t, "val2", f1, 3) + requireStringAt(t, "val1", f1, 4) + requireStringAt(t, "val1", f1, 5) + + requireStringAt(t, "info", f2, 0) + requireStringAt(t, "error", f2, 1) + requireStringAt(t, "info", f2, 2) + requireStringAt(t, "error", f2, 3) + requireStringAt(t, "info", f2, 4) + requireStringAt(t, "error", f2, 5) + + requireFloatAt(t, 299, f3, 0) + requireFloatAt(t, 300, f3, 1) + requireFloatAt(t, 300, f3, 2) + requireFloatAt(t, 298, f3, 3) + requireFloatAt(t, 299, f3, 4) + requireFloatAt(t, 296, f3, 5) + }) + + t.Run("Terms agg without date histogram", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { "type": "avg", "id": "1", "field": "@value" }, + { "type": "count", "id": "3" } + ], + "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "1": { "value": 1000 }, "key": "server-1", "doc_count": 369 }, + { "1": { "value": 2000 }, "key": "server-2", "doc_count": 200 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1) + + frame1 := frames[0] + requireFrameLength(t, frame1, 2) + require.Len(t, frame1.Fields, 3) + + f1 := frame1.Fields[0] + f2 := frame1.Fields[1] + f3 := frame1.Fields[2] + + requireStringAt(t, "server-1", f1, 0) + requireStringAt(t, "server-2", f1, 1) + + requireFloatAt(t, 1000.0, f2, 0) + requireFloatAt(t, 2000.0, f2, 1) + + requireFloatAt(t, 369.0, f3, 0) + requireFloatAt(t, 200.0, f3, 1) + }) + }) + + t.Run("Top metrics", func(t *testing.T) { + t.Run("Top metrics 2 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { + "type": "top_metrics", + "settings": { + "order": "top", + "orderBy": "@timestamp", + "metrics": ["@value", "@anotherValue"] + }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "date_histogram", "id": "2" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { + "top": [ + { + "sort": ["2021-01-01T00:00:00.000Z"], + "metrics": { "@value": 1, "@anotherValue": 2 } + } + ] + }, + "key": 1609459200000, + "key_as_string": "2021-01-01T00:00:00.000Z" + }, + { + "1": { + "top": [ + { + "sort": ["2021-01-01T00:00:10.000Z"], + "metrics": { "@value": 1, "@anotherValue": 2 } + } + ] + }, + "key": 1609459210000, + "key_as_string": "2021-01-01T00:00:10.000Z" + } + ] + } + } + } + ] + } + `) + + time1, err := time.Parse(time.RFC3339, "2021-01-01T00:00:00.000Z") + require.NoError(t, err) + time2, err := time.Parse(time.RFC3339, "2021-01-01T00:00:10.000Z") + require.NoError(t, err) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + frame1 := frames[0] + frame2 := frames[1] + + requireTimeSeriesName(t, "Top Metrics @value", frame1) + requireFrameLength(t, frame1, 2) + requireTimeValue(t, time1.UTC().UnixMilli(), frame1, 0) + requireTimeValue(t, time2.UTC().UnixMilli(), frame1, 1) + requireNumberValue(t, 1, frame1, 0) + requireNumberValue(t, 1, frame1, 1) + + requireTimeSeriesName(t, "Top Metrics @anotherValue", frame2) + requireFrameLength(t, frame2, 2) + requireTimeValue(t, time1.UTC().UnixMilli(), frame2, 0) + requireTimeValue(t, time2.UTC().UnixMilli(), frame2, 1) + requireNumberValue(t, 2, frame2, 0) + requireNumberValue(t, 2, frame2, 1) + }) + + t.Run("With top_metrics and date_histogram agg", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [ + { + "type": "top_metrics", + "settings": { + "order": "desc", + "orderBy": "@timestamp", + "metrics": ["@value", "@anotherValue"] + }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "3" }] + }`, + } + response := `{ + "responses": [{ + "aggregations": { + "3": { + "buckets": [ + { + "key": 1609459200000, + "key_as_string": "2021-01-01T00:00:00.000Z", + "1": { + "top": [ + { "sort": ["2021-01-01T00:00:00.000Z"], "metrics": { "@value": 1, "@anotherValue": 2 } } + ] + } + }, + { + "key": 1609459210000, + "key_as_string": "2021-01-01T00:00:10.000Z", + "1": { + "top": [ + { "sort": ["2021-01-01T00:00:10.000Z"], "metrics": { "@value": 1, "@anotherValue": 2 } } + ] + } + } + ] + } + } + }] + }` + result, err := parseTestResponse(targets, response) + assert.Nil(t, err) + assert.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + assert.NotNil(t, queryRes) + dataframes := queryRes.Frames + assert.NoError(t, err) + assert.Len(t, dataframes, 2) + + frame := dataframes[0] + assert.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @value") + v, _ := frame.FloatAt(0, 0) + assert.Equal(t, 1609459200000., v) + v, _ = frame.FloatAt(1, 0) + assert.Equal(t, 1., v) + + v, _ = frame.FloatAt(0, 1) + assert.Equal(t, 1609459210000., v) + v, _ = frame.FloatAt(1, 1) + assert.Equal(t, 1., v) + + frame = dataframes[1] + l, _ := frame.MarshalJSON() + fmt.Println(string(l)) + assert.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @anotherValue") + v, _ = frame.FloatAt(0, 0) + assert.Equal(t, 1609459200000., v) + v, _ = frame.FloatAt(1, 0) + assert.Equal(t, 2., v) + + v, _ = frame.FloatAt(0, 1) + assert.Equal(t, 1609459210000., v) + v, _ = frame.FloatAt(1, 1) + assert.Equal(t, 2., v) + }) + + t.Run("With top_metrics and terms agg", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [ + { + "type": "top_metrics", + "settings": { + "order": "desc", + "orderBy": "@timestamp", + "metrics": ["@value", "@anotherValue"] + }, + "id": "1" + } + ], + "bucketAggs": [{ "type": "terms", "field": "id", "id": "3" }] + }`, + } + response := `{ + "responses": [{ + "aggregations": { + "3": { + "buckets": [ + { + "key": "id1", + "1": { + "top": [ + { "sort": [10], "metrics": { "@value": 10, "@anotherValue": 2 } } + ] + } + }, + { + "key": "id2", + "1": { + "top": [ + { "sort": [5], "metrics": { "@value": 5, "@anotherValue": 2 } } + ] + } + } + ] + } + } + }] + }` + + result, err := parseTestResponse(targets, response) + assert.Nil(t, err) + assert.Len(t, result.Responses, 1) + frames := result.Responses["A"].Frames + require.Len(t, frames, 1) + requireFrameLength(t, frames[0], 2) + require.Len(t, frames[0].Fields, 3) + + f1 := frames[0].Fields[0] + f2 := frames[0].Fields[1] + f3 := frames[0].Fields[2] + + require.Equal(t, "id", f1.Name) + require.Equal(t, "Top Metrics @value", f2.Name) + require.Equal(t, "Top Metrics @anotherValue", f3.Name) + + requireStringAt(t, "id1", f1, 0) + requireStringAt(t, "id2", f1, 1) + + requireFloatAt(t, 10, f2, 0) + requireFloatAt(t, 5, f2, 1) + + requireFloatAt(t, 2, f3, 0) + requireFloatAt(t, 2, f3, 1) + }) + }) + + t.Run("Group by", func(t *testing.T) { + t.Run("Simple group by 1 metric 2 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "server1", frames[0]) + requireTimeSeriesName(t, "server2", frames[1]) + }) + + t.Run("Single group with alias pattern 3 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "alias": "{{term @host}} {{metric}} and {{not_exist}} {{@host}}", + "bucketAggs": [ + { "type": "terms", "field": "@host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + }, + { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + }, + "doc_count": 10, + "key": 0 + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 3) + + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "server1 Count and {{not_exist}} server1", frames[0]) + requireTimeSeriesName(t, "server2 Count and {{not_exist}} server2", frames[1]) + requireTimeSeriesName(t, "0 Count and {{not_exist}} 0", frames[2]) }) t.Run("Single group by query one metric", func(t *testing.T) { @@ -319,28 +1744,108 @@ func TestResponseParser(t *testing.T) { assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Average @value") }) - t.Run("With percentiles", func(t *testing.T) { + t.Run("Simple group by 2 metrics 4 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { "type": "count", "id": "1" }, + { "type": "avg", "field": "@value", "id": "4" } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "3": { + "buckets": [ + { "4": { "value": 10 }, "doc_count": 1, "key": 1000 }, + { "4": { "value": 12 }, "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 4, + "key": "server1" + }, + { + "3": { + "buckets": [ + { "4": { "value": 20 }, "doc_count": 1, "key": 1000 }, + { "4": { "value": 32 }, "doc_count": 3, "key": 2000 } + ] + }, + "doc_count": 10, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 4) + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "server1 Count", frames[0]) + requireTimeSeriesName(t, "server1 Average @value", frames[1]) + requireTimeSeriesName(t, "server2 Count", frames[2]) + requireTimeSeriesName(t, "server2 Average @value", frames[3]) + }) + + t.Run("Single group by with alias pattern", func(t *testing.T) { targets := map[string]string{ "A": `{ - "metrics": [{ "type": "percentiles", "settings": { "percents": [75, 90] }, "id": "1" }], - "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "3" }] + "alias": "{{term @host}} {{metric}} and {{not_exist}} {{@host}}", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "terms", "field": "@host", "id": "2" }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] }`, } response := `{ "responses": [ { "aggregations": { - "3": { + "2": { "buckets": [ { - "1": { "values": { "75": 3.3, "90": 5.5 } }, - "doc_count": 10, - "key": 1000 + "3": { + "buckets": [{ "doc_count": 1, "key": 1000 }, { "doc_count": 3, "key": 2000 }] + }, + "doc_count": 4, + "key": "server1" }, { - "1": { "values": { "75": 2.3, "90": 4.5 } }, - "doc_count": 15, - "key": 2000 + "3": { + "buckets": [{ "doc_count": 2, "key": 1000 }, { "doc_count": 8, "key": 2000 }] + }, + "doc_count": 10, + "key": "server2" + }, + { + "3": { + "buckets": [{ "doc_count": 2, "key": 1000 }, { "doc_count": 8, "key": 2000 }] + }, + "doc_count": 10, + "key": 0 } ] } @@ -356,7 +1861,7 @@ func TestResponseParser(t *testing.T) { require.NotNil(t, queryRes) dataframes := queryRes.Frames require.NoError(t, err) - require.Len(t, dataframes, 2) + require.Len(t, dataframes, 3) frame := dataframes[0] require.Len(t, frame.Fields, 2) @@ -364,7 +1869,7 @@ func TestResponseParser(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p75") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Count and {{not_exist}} server1") frame = dataframes[1] require.Len(t, frame.Fields, 2) @@ -372,7 +1877,99 @@ func TestResponseParser(t *testing.T) { require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "p90") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Count and {{not_exist}} server2") + + frame = dataframes[2] + require.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) + require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "0 Count and {{not_exist}} 0") + }) + }) + + t.Run("Extended stats", func(t *testing.T) { + t.Run("Extended stats 4 frames", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { + "type": "extended_stats", + "meta": { "max": true, "std_deviation_bounds_upper": true }, + "id": "1", + "field": "@value" + } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "3" }, + { "type": "date_histogram", "id": "4" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "4": { + "buckets": [ + { + "1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + }, + "key": "server1" + }, + { + "4": { + "buckets": [ + { + "1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + }, + "key": "server2" + } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 4) + requireFrameLength(t, frames[0], 1) + requireTimeSeriesName(t, "server1 Max @value", frames[0]) + requireTimeSeriesName(t, "server1 Std Dev Upper @value", frames[1]) + + requireNumberValue(t, 10.2, frames[0], 0) + requireNumberValue(t, 3, frames[1], 0) }) t.Run("With extended stats", func(t *testing.T) { @@ -487,16 +2084,58 @@ func TestResponseParser(t *testing.T) { require.Equal(t, frame.Fields[1].Len(), 1) assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Std Dev Upper") }) + }) - t.Run("Single group by with alias pattern", func(t *testing.T) { + t.Run("Count", func(t *testing.T) { + t.Run("Simple query returns 1 frame", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "doc_count": 10, "key": 1000 }, + { "doc_count": 15, "key": 2000 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 1, "frame-count wrong") + frame := frames[0] + requireTimeSeriesName(t, "Count", frame) + + requireFrameLength(t, frame, 2) + requireTimeValue(t, 1000, frame, 0) + requireNumberValue(t, 10, frame, 0) + }) + + t.Run("Simple count with date_histogram aggregation", func(t *testing.T) { targets := map[string]string{ "A": `{ - "alias": "{{term @host}} {{metric}} and {{not_exist}} {{@host}}", "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [ - { "type": "terms", "field": "@host", "id": "2" }, - { "type": "date_histogram", "field": "@timestamp", "id": "3" } - ] + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "2" }] }`, } response := `{ @@ -506,25 +2145,60 @@ func TestResponseParser(t *testing.T) { "2": { "buckets": [ { - "3": { - "buckets": [{ "doc_count": 1, "key": 1000 }, { "doc_count": 3, "key": 2000 }] - }, - "doc_count": 4, - "key": "server1" + "doc_count": 10, + "key": 1000 }, { - "3": { - "buckets": [{ "doc_count": 2, "key": 1000 }, { "doc_count": 8, "key": 2000 }] - }, + "doc_count": 15, + "key": 2000 + } + ] + } + } + } + ] + }` + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.Len(t, dataframes, 1) + + frame := dataframes[0] + require.Len(t, frame.Fields, 2) + + require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) + require.Equal(t, frame.Fields[1].Len(), 2) + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") + }) + + t.Run("Simple query count & avg aggregation", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "count", "id": "1" }, {"type": "avg", "field": "value", "id": "2" }], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "3" }] + }`, + } + response := `{ + "responses": [ + { + "aggregations": { + "3": { + "buckets": [ + { + "2": { "value": 88 }, "doc_count": 10, - "key": "server2" + "key": 1000 }, { - "3": { - "buckets": [{ "doc_count": 2, "key": 1000 }, { "doc_count": 8, "key": 2000 }] - }, - "doc_count": 10, - "key": 0 + "2": { "value": 99 }, + "doc_count": 15, + "key": 2000 } ] } @@ -540,49 +2214,58 @@ func TestResponseParser(t *testing.T) { require.NotNil(t, queryRes) dataframes := queryRes.Frames require.NoError(t, err) - require.Len(t, dataframes, 3) + require.Len(t, dataframes, 2) frame := dataframes[0] require.Len(t, frame.Fields, 2) + require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server1 Count and {{not_exist}} server1") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") frame = dataframes[1] require.Len(t, frame.Fields, 2) - require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) - require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "server2 Count and {{not_exist}} server2") - frame = dataframes[2] - require.Len(t, frame.Fields, 2) require.Equal(t, frame.Fields[0].Name, data.TimeSeriesTimeFieldName) require.Equal(t, frame.Fields[0].Len(), 2) require.Equal(t, frame.Fields[1].Name, data.TimeSeriesValueFieldName) require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "0 Count and {{not_exist}} 0") + assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Average value") }) + }) - t.Run("Histogram response", func(t *testing.T) { + t.Run("Avg", func(t *testing.T) { + t.Run("Query with duplicated avg metric creates unique field name", func(t *testing.T) { targets := map[string]string{ "A": `{ - "metrics": [{ "type": "count", "id": "1" }], - "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "3" }] + "metrics": [{"type": "avg", "field": "value", "id": "1" }, {"type": "avg", "field": "value", "id": "4" }], + "bucketAggs": [{ "type": "terms", "field": "label", "id": "3" }] }`, } response := `{ "responses": [ - { - "aggregations": { - "3": { - "buckets": [{ "doc_count": 1, "key": 1000 }, { "doc_count": 3, "key": 2000 }, { "doc_count": 2, "key": 3000 }] - } - } - } + { + "aggregations": { + "3": { + "buckets": [ + { + "1": { "value": 88 }, + "4": { "value": 88 }, + "doc_count": 10, + "key": "val1" + }, + { + "1": { "value": 99 }, + "4": { "value": 99 }, + "doc_count": 15, + "key": "val2" + } + ] + } + } + } ] }` result, err := parseTestResponse(targets, response) @@ -594,6 +2277,79 @@ func TestResponseParser(t *testing.T) { dataframes := queryRes.Frames require.NoError(t, err) require.Len(t, dataframes, 1) + + frame := dataframes[0] + require.Len(t, frame.Fields, 3) + require.Equal(t, frame.Fields[0].Name, "label") + require.Equal(t, frame.Fields[1].Name, "Average value 1") + require.Equal(t, frame.Fields[2].Name, "Average value 4") + }) + }) + + t.Run("Multiple bucket agg", func(t *testing.T) { + t.Run("Date histogram with 2 filters agg", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { + "id": "2", + "type": "filters", + "settings": { + "filters": [ + { "query": "@metric:cpu", "label": "" }, + { "query": "@metric:logins.count", "label": "" } + ] + } + }, + { "type": "date_histogram", "field": "@timestamp", "id": "3" } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": { + "@metric:cpu": { + "3": { + "buckets": [ + { "doc_count": 1, "key": 1000 }, + { "doc_count": 3, "key": 2000 } + ] + } + }, + "@metric:logins.count": { + "3": { + "buckets": [ + { "doc_count": 2, "key": 1000 }, + { "doc_count": 8, "key": 2000 } + ] + } + } + } + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "@metric:cpu", frames[0]) + requireTimeSeriesName(t, "@metric:logins.count", frames[1]) }) t.Run("With two filters agg", func(t *testing.T) { @@ -660,6 +2416,154 @@ func TestResponseParser(t *testing.T) { require.Equal(t, frame.Fields[1].Len(), 2) assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "@metric:logins.count") }) + }) + + t.Run("With multiple metrics", func(t *testing.T) { + t.Run("Multiple metrics with the same type", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { "type": "avg", "id": "1", "field": "test" }, + { "type": "avg", "id": "2", "field": "test2" } + ], + "bucketAggs": [{ "id": "2", "type": "terms", "field": "host" }] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 1000 }, + "2": { "value": 3000 }, + "key": "server-1", + "doc_count": 369 + } + ] + } + } + } + ] + } + + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.True(t, len(frames) > 0) + requireFrameLength(t, frames[0], 1) + require.Len(t, frames[0].Fields, 3) + + requireStringAt(t, "server-1", frames[0].Fields[0], 0) + requireFloatAt(t, 1000.0, frames[0].Fields[1], 0) + requireFloatAt(t, 3000.0, frames[0].Fields[2], 0) + }) + + t.Run("Multiple metrics of same type", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "avg", "field": "test", "id": "1" }, { "type": "avg", "field": "test2", "id": "2" }], + "bucketAggs": [{ "type": "terms", "field": "host", "id": "2" }] + }`, + } + response := `{ + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 1000 }, + "2": { "value": 3000 }, + "key": "server-1", + "doc_count": 369 + } + ] + } + } + } + ] + }` + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.NoError(t, err) + require.Len(t, dataframes, 1) + + frame := dataframes[0] + require.Len(t, frame.Fields, 3) + require.Equal(t, frame.Fields[0].Name, "host") + require.Equal(t, frame.Fields[0].Len(), 1) + require.Equal(t, frame.Fields[1].Name, "Average test") + require.Equal(t, frame.Fields[1].Len(), 1) + require.Equal(t, frame.Fields[2].Name, "Average test2") + require.Equal(t, frame.Fields[2].Len(), 1) + require.Nil(t, frame.Fields[1].Config) + }) + + t.Run("No group by time", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "avg", "id": "1" }, { "type": "count" }], + "bucketAggs": [{ "type": "terms", "field": "host", "id": "2" }] + }`, + } + response := `{ + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 1000 }, + "key": "server-1", + "doc_count": 369 + }, + { + "1": { "value": 2000 }, + "key": "server-2", + "doc_count": 200 + } + ] + } + } + } + ] + }` + result, err := parseTestResponse(targets, response) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + queryRes := result.Responses["A"] + require.NotNil(t, queryRes) + dataframes := queryRes.Frames + require.NoError(t, err) + require.Len(t, dataframes, 1) + + frame := dataframes[0] + require.Len(t, frame.Fields, 3) + require.Equal(t, frame.Fields[0].Name, "host") + require.Equal(t, frame.Fields[0].Len(), 2) + require.Equal(t, frame.Fields[1].Name, "Average") + require.Equal(t, frame.Fields[1].Len(), 2) + require.Equal(t, frame.Fields[2].Name, "Count") + require.Equal(t, frame.Fields[2].Len(), 2) + require.Nil(t, frame.Fields[1].Config) + }) t.Run("With drop first and last aggregation (numeric)", func(t *testing.T) { targets := map[string]string{ @@ -796,7 +2700,9 @@ func TestResponseParser(t *testing.T) { require.Equal(t, frame.Fields[1].Len(), 1) assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Count") }) + }) + t.Run("Trim edges", func(t *testing.T) { t.Run("Larger trimEdges value", func(t *testing.T) { targets := map[string]string{ "A": `{ @@ -839,106 +2745,11 @@ func TestResponseParser(t *testing.T) { queryRes := result.Responses["A"] require.NotNil(t, queryRes) - experimental.CheckGoldenJSONResponse(t, "testdata", "trimedges_string.golden", &queryRes, *update) }) + }) - t.Run("No group by time", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "avg", "id": "1" }, { "type": "count" }], - "bucketAggs": [{ "type": "terms", "field": "host", "id": "2" }] - }`, - } - response := `{ - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 1000 }, - "key": "server-1", - "doc_count": 369 - }, - { - "1": { "value": 2000 }, - "key": "server-2", - "doc_count": 200 - } - ] - } - } - } - ] - }` - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.NoError(t, err) - require.Len(t, dataframes, 1) - - frame := dataframes[0] - require.Len(t, frame.Fields, 3) - require.Equal(t, frame.Fields[0].Name, "host") - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Name, "Average") - require.Equal(t, frame.Fields[1].Len(), 2) - require.Equal(t, frame.Fields[2].Name, "Count") - require.Equal(t, frame.Fields[2].Len(), 2) - require.Nil(t, frame.Fields[1].Config) - }) - - t.Run("Multiple metrics of same type", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "avg", "field": "test", "id": "1" }, { "type": "avg", "field": "test2", "id": "2" }], - "bucketAggs": [{ "type": "terms", "field": "host", "id": "2" }] - }`, - } - response := `{ - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 1000 }, - "2": { "value": 3000 }, - "key": "server-1", - "doc_count": 369 - } - ] - } - } - } - ] - }` - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.NoError(t, err) - require.Len(t, dataframes, 1) - - frame := dataframes[0] - require.Len(t, frame.Fields, 3) - require.Equal(t, frame.Fields[0].Name, "host") - require.Equal(t, frame.Fields[0].Len(), 1) - require.Equal(t, frame.Fields[1].Name, "Average test") - require.Equal(t, frame.Fields[1].Len(), 1) - require.Equal(t, frame.Fields[2].Name, "Average test2") - require.Equal(t, frame.Fields[2].Len(), 1) - require.Nil(t, frame.Fields[1].Config) - }) - + t.Run("Bucket script", func(t *testing.T) { t.Run("With bucket_script", func(t *testing.T) { targets := map[string]string{ "A": `{ @@ -1016,504 +2827,68 @@ func TestResponseParser(t *testing.T) { assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Sum @value * Max @value") }) - t.Run("Terms with two bucket_script", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [ - { "id": "1", "type": "sum", "field": "@value" }, - { "id": "3", "type": "max", "field": "@value" }, - { - "id": "4", - "pipelineVariables": [{ "name": "var1", "pipelineAgg": "1" }, { "name": "var2", "pipelineAgg": "3" }], - "settings": { "script": "params.var1 * params.var2" }, - "type": "bucket_script" - }, - { - "id": "5", - "pipelineVariables": [{ "name": "var1", "pipelineAgg": "1" }, { "name": "var2", "pipelineAgg": "3" }], - "settings": { "script": "params.var1 * params.var2 * 2" }, - "type": "bucket_script" - } - ], - "bucketAggs": [{ "type": "terms", "field": "@timestamp", "id": "2" }] - }`, - } - response := `{ - "responses": [ - { - "aggregations": { - "2": { - "buckets": [ - { - "1": { "value": 2 }, - "3": { "value": 3 }, - "4": { "value": 6 }, - "5": { "value": 24 }, - "doc_count": 60, - "key": 1000 - }, - { - "1": { "value": 3 }, - "3": { "value": 4 }, - "4": { "value": 12 }, - "5": { "value": 48 }, - "doc_count": 60, - "key": 2000 - } - ] - } - } - } - ] - }` - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.NoError(t, err) - require.Len(t, dataframes, 1) - - frame := dataframes[0] - require.Len(t, frame.Fields, 5) - require.Equal(t, frame.Fields[0].Name, "@timestamp") - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Name, "Sum") - require.Equal(t, frame.Fields[1].Len(), 2) - require.Equal(t, frame.Fields[2].Name, "Max") - require.Equal(t, frame.Fields[2].Len(), 2) - require.Equal(t, frame.Fields[3].Name, "params.var1 * params.var2") - require.Equal(t, frame.Fields[3].Len(), 2) - require.Equal(t, frame.Fields[4].Name, "params.var1 * params.var2 * 2") - require.Equal(t, frame.Fields[4].Len(), 2) - require.Nil(t, frame.Fields[1].Config) - }) - - t.Run("Log query", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "logs" }] - }`, - } - - response := `{ - "responses":[ - { - "hits":{ - "total":{ - "value":109, - "relation":"eq" - }, - "max_score":null, - "hits":[ - { - "_index":"logs-2023.02.08", - "_id":"GB2UMYYBfCQ-FCMjayJa", - "_score":null, - "_source":{ - "@timestamp":"2023-02-08T15:10:55.830Z", - "line":"log text [479231733]", - "counter":"109", - "float":58.253758485091, - "label":"val1", - "lvl":"info", - "location":"17.089705232090438, 41.62861966340297", - "nested": { - "field": { - "double_nested": "value" - } - }, - "shapes":[ - { - "type":"triangle" - }, - { - "type":"square" - } - ], - "xyz": null - }, - "sort":[ - 1675869055830, - 4 - ] - }, - { - "_index":"logs-2023.02.08", - "_id":"Fx2UMYYBfCQ-FCMjZyJ_", - "_score":null, - "_source":{ - "@timestamp":"2023-02-08T15:10:54.835Z", - "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", - "counter":"108", - "float":54.5977098233944, - "label":"val1", - "lvl":"info", - "location":"19.766305918490463, 40.42639175509792", - "nested": { - "field": { - "double_nested": "value" - } - }, - "shapes":[ - { - "type":"triangle" - }, - { - "type":"square" - } - ], - "xyz": "def" - }, - "sort":[ - 1675869054835, - 7 - ] - } - ] - }, - "status":200 - } - ] - }` - - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.Len(t, dataframes, 1) - frame := dataframes[0] - - require.Equal(t, 16, len(frame.Fields)) - // Fields have the correct length - require.Equal(t, 2, frame.Fields[0].Len()) - // First field is timeField - require.Equal(t, data.FieldTypeNullableTime, frame.Fields[0].Type()) - // Second is log line - require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) - require.Equal(t, "line", frame.Fields[1].Name) - // Correctly renames lvl field to level - require.Equal(t, "level", frame.Fields[10].Name) - // Correctly uses string types - require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) - // Correctly detects float64 types - require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[7].Type()) - // Correctly detects json types - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[8].Type()) - // Correctly flattens fields - require.Equal(t, "nested.field.double_nested", frame.Fields[12].Name) - require.Equal(t, data.FieldTypeNullableString, frame.Fields[12].Type()) - // Correctly detects type even if first value is null - require.Equal(t, data.FieldTypeNullableString, frame.Fields[15].Type()) - }) - - t.Run("Log query with highlight", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "logs" }] - }`, - } - - response := `{ - "responses":[ - { - "hits":{ - "total":{ - "value":109, - "relation":"eq" - }, - "max_score":null, - "hits":[ - { - "_index":"logs-2023.02.08", - "_id":"GB2UMYYBfCQ-FCMjayJa", - "_score":null, - "highlight": { - "line": [ - "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" - ], - "duplicated": ["@HIGHLIGHT@hello@/HIGHLIGHT@"] - }, - "_source":{ - "@timestamp":"2023-02-08T15:10:55.830Z", - "line":"log text [479231733]" - } - }, - { - "_index":"logs-2023.02.08", - "_id":"GB2UMYYBfCQ-FCMjayJa", - "_score":null, - "highlight": { - "line": [ - "@HIGHLIGHT@hello@/HIGHLIGHT@, i am a @HIGHLIGHT@message@/HIGHLIGHT@" - ], - "duplicated": ["@HIGHLIGHT@hello@/HIGHLIGHT@"] - }, - "_source":{ - "@timestamp":"2023-02-08T15:10:55.830Z", - "line":"log text [479231733]" - } - } - ] - }, - "status":200 - } - ] - }` - - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.Len(t, dataframes, 1) - frame := dataframes[0] - - customMeta := frame.Meta.Custom - - require.Equal(t, map[string]interface{}{ - "searchWords": []string{"hello", "message"}, - }, customMeta) - }) - - t.Run("Raw document query", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "raw_document" }] - }`, - } - - response := `{ - "responses":[ - { - "hits":{ - "total":{ - "value":109, - "relation":"eq" - }, - "max_score":null, - "hits":[ - { - "_index":"logs-2023.02.08", - "_id":"GB2UMYYBfCQ-FCMjayJa", - "_score":null, - "fields": { - "test_field":"A" - }, - "_source":{ - "@timestamp":"2023-02-08T15:10:55.830Z", - "line":"log text [479231733]", - "counter":"109", - "float":58.253758485091, - "label":"val1", - "level":"info", - "location":"17.089705232090438, 41.62861966340297", - "nested": { - "field": { - "double_nested": "value" - } - } - } - }, - { - "_index":"logs-2023.02.08", - "_id":"Fx2UMYYBfCQ-FCMjZyJ_", - "_score":null, - "fields": { - "test_field":"A" - }, - "_source":{ - "@timestamp":"2023-02-08T15:10:54.835Z", - "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", - "counter":"108", - "float":54.5977098233944, - "label":"val1", - "level":"info", - "location":"19.766305918490463, 40.42639175509792", - "nested": { - "field": { - "double_nested": "value1" - } - } - } - } - ] - }, - "status":200 - } - ] - }` - - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.Len(t, dataframes, 1) - frame := dataframes[0] - - require.Equal(t, 1, len(frame.Fields)) - //Fields have the correct length - require.Equal(t, 2, frame.Fields[0].Len()) - // The only field is the raw document - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[0].Type()) - require.Equal(t, "A", frame.Fields[0].Name) - }) - - t.Run("Raw data query", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [{ "type": "raw_data" }] - }`, - } - - response := `{ - "responses":[ - { - "hits":{ - "total":{ - "value":109, - "relation":"eq" - }, - "max_score":null, - "hits":[ - { - "_index":"logs-2023.02.08", - "_id":"GB2UMYYBfCQ-FCMjayJa", - "_score":null, - "_source":{ - "@timestamp":"2023-02-08T15:10:55.830Z", - "line":"log text [479231733]", - "counter":"109", - "float":58.253758485091, - "label":"val1", - "level":"info", - "location":"17.089705232090438, 41.62861966340297", - "nested": { - "field": { - "double_nested": "value" - } - }, - "shapes":[ - { - "type":"triangle" - }, - { - "type":"square" - } - ], - "xyz": null - }, - "sort":[ - 1675869055830, - 4 - ] - }, - { - "_index":"logs-2023.02.08", - "_id":"Fx2UMYYBfCQ-FCMjZyJ_", - "_score":null, - "_source":{ - "@timestamp":"2023-02-08T15:10:54.835Z", - "line":"log text with ANSI \u001b[31mpart of the text\u001b[0m [493139080]", - "counter":"108", - "float":54.5977098233944, - "label":"val1", - "level":"info", - "location":"19.766305918490463, 40.42639175509792", - "nested": { - "field": { - "double_nested": "value" - } - }, - "shapes":[ - { - "type":"triangle" - }, - { - "type":"square" - } - ], - "xyz": "def" - }, - "sort":[ - 1675869054835, - 7 - ] - } - ] - }, - "status":200 - } - ] - }` - - result, err := parseTestResponse(targets, response) - require.NoError(t, err) - require.Len(t, result.Responses, 1) - - queryRes := result.Responses["A"] - require.NotNil(t, queryRes) - dataframes := queryRes.Frames - require.Len(t, dataframes, 1) - frame := dataframes[0] - - require.Equal(t, 15, len(frame.Fields)) - // Fields have the correct length - require.Equal(t, 2, frame.Fields[0].Len()) - // First field is timeField - require.Equal(t, data.FieldTypeNullableTime, frame.Fields[0].Type()) - // Correctly uses string types - require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type()) - // Correctly detects float64 types - require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[5].Type()) - // Correctly detects json types - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[6].Type()) - // Correctly flattens fields - require.Equal(t, "nested.field.double_nested", frame.Fields[11].Name) - require.Equal(t, data.FieldTypeNullableString, frame.Fields[11].Type()) - // Correctly detects type even if first value is null - require.Equal(t, data.FieldTypeNullableString, frame.Fields[14].Type()) - }) - t.Run("Raw data query filterable fields", func(t *testing.T) { + t.Run("Two bucket_script", func(t *testing.T) { query := []byte(` - [ - { - "refId": "A", - "metrics": [{ "type": "raw_data", "id": "1" }], - "bucketAggs": [] - } - ] - `) + [ + { + "refId": "A", + "metrics": [ + { "id": "1", "type": "sum", "field": "@value" }, + { "id": "3", "type": "max", "field": "@value" }, + { + "id": "4", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2" }, + "type": "bucket_script" + }, + { + "id": "5", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2 * 4" }, + "type": "bucket_script" + } + ], + "bucketAggs": [{ "type": "terms", "field": "@timestamp", "id": "2" }] + } + ] + `) response := []byte(` - { - "responses": [ - { - "hits": { - "total": { "relation": "eq", "value": 1 }, - "hits": [ - { - "_id": "1", - "_type": "_doc", - "_index": "index", - "_source": { "sourceProp": "asd" } - } - ] - } - } - ] - } - `) + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 2 }, + "3": { "value": 3 }, + "4": { "value": 6 }, + "5": { "value": 24 }, + "doc_count": 60, + "key": 1000 + }, + { + "1": { "value": 3 }, + "3": { "value": 4 }, + "4": { "value": 12 }, + "5": { "value": 48 }, + "doc_count": 60, + "key": 2000 + } + ] + } + } + } + ] + } + `) result, err := queryDataTest(query, response) require.NoError(t, err) @@ -1521,298 +2896,282 @@ func TestResponseParser(t *testing.T) { require.Len(t, result.response.Responses, 1) frames := result.response.Responses["A"].Frames require.True(t, len(frames) > 0) + requireFrameLength(t, frames[0], 2) - for _, field := range frames[0].Fields { - trueValue := true - filterableConfig := data.FieldConfig{Filterable: &trueValue} + fields := frames[0].Fields + require.Len(t, fields, 5) - // we need to test that the only changed setting is `filterable` - require.Equal(t, filterableConfig, *field.Config) - } + requireFloatAt(t, 1000.0, fields[0], 0) + requireFloatAt(t, 2000.0, fields[0], 1) + requireFloatAt(t, 2.0, fields[1], 0) + requireFloatAt(t, 3.0, fields[1], 1) + requireFloatAt(t, 3.0, fields[2], 0) + requireFloatAt(t, 4.0, fields[2], 1) + requireFloatAt(t, 6.0, fields[3], 0) + requireFloatAt(t, 12.0, fields[3], 1) + requireFloatAt(t, 24.0, fields[4], 0) + requireFloatAt(t, 48.0, fields[4], 1) }) - }) - t.Run("With top_metrics and date_histogram agg", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [ - { - "type": "top_metrics", - "settings": { - "order": "desc", - "orderBy": "@timestamp", - "metrics": ["@value", "@anotherValue"] - }, - "id": "1" - } - ], - "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "3" }] - }`, + t.Run("Bucket script", func(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { "id": "1", "type": "sum", "field": "@value" }, + { "id": "3", "type": "max", "field": "@value" }, + { + "id": "4", + "pipelineVariables": [ + { "name": "var1", "pipelineAgg": "1" }, + { "name": "var2", "pipelineAgg": "3" } + ], + "settings": { "script": "params.var1 * params.var2" }, + "type": "bucket_script" + } + ], + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ] } - response := `{ - "responses": [{ - "aggregations": { - "3": { - "buckets": [ - { - "key": 1609459200000, - "key_as_string": "2021-01-01T00:00:00.000Z", - "1": { - "top": [ - { "sort": ["2021-01-01T00:00:00.000Z"], "metrics": { "@value": 1, "@anotherValue": 2 } } - ] - } - }, - { - "key": 1609459210000, - "key_as_string": "2021-01-01T00:00:10.000Z", - "1": { - "top": [ - { "sort": ["2021-01-01T00:00:10.000Z"], "metrics": { "@value": 1, "@anotherValue": 2 } } - ] - } - } - ] - } - } - }] - }` - result, err := parseTestResponse(targets, response) - assert.Nil(t, err) - assert.Len(t, result.Responses, 1) + ] + `) - queryRes := result.Responses["A"] - assert.NotNil(t, queryRes) - dataframes := queryRes.Frames - assert.NoError(t, err) - assert.Len(t, dataframes, 2) + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { + "1": { "value": 2 }, + "3": { "value": 3 }, + "4": { "value": 6 }, + "doc_count": 60, + "key": 1000 + }, + { + "1": { "value": 3 }, + "3": { "value": 4 }, + "4": { "value": 12 }, + "doc_count": 60, + "key": 2000 + } + ] + } + } + } + ] + } + `) - frame := dataframes[0] - assert.Len(t, frame.Fields, 2) - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @value") - v, _ := frame.FloatAt(0, 0) - assert.Equal(t, 1609459200000., v) - v, _ = frame.FloatAt(1, 0) - assert.Equal(t, 1., v) + result, err := queryDataTest(query, response) + require.NoError(t, err) - v, _ = frame.FloatAt(0, 1) - assert.Equal(t, 1609459210000., v) - v, _ = frame.FloatAt(1, 1) - assert.Equal(t, 1., v) + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 3) + requireFrameLength(t, frames[0], 2) + requireTimeSeriesName(t, "Sum @value", frames[0]) + requireTimeSeriesName(t, "Max @value", frames[1]) + requireTimeSeriesName(t, "Sum @value * Max @value", frames[2]) - frame = dataframes[1] - l, _ := frame.MarshalJSON() - fmt.Println(string(l)) - assert.Len(t, frame.Fields, 2) - require.Equal(t, frame.Fields[0].Len(), 2) - require.Equal(t, frame.Fields[1].Len(), 2) - assert.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "Top Metrics @anotherValue") - v, _ = frame.FloatAt(0, 0) - assert.Equal(t, 1609459200000., v) - v, _ = frame.FloatAt(1, 0) - assert.Equal(t, 2., v) + requireNumberValue(t, 2, frames[0], 0) + requireNumberValue(t, 3, frames[1], 0) + requireNumberValue(t, 6, frames[2], 0) - v, _ = frame.FloatAt(0, 1) - assert.Equal(t, 1609459210000., v) - v, _ = frame.FloatAt(1, 1) - assert.Equal(t, 2., v) - }) - - t.Run("With top_metrics and terms agg", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [ - { - "type": "top_metrics", - "settings": { - "order": "desc", - "orderBy": "@timestamp", - "metrics": ["@value", "@anotherValue"] - }, - "id": "1" - } - ], - "bucketAggs": [{ "type": "terms", "field": "id", "id": "3" }] - }`, - } - response := `{ - "responses": [{ - "aggregations": { - "3": { - "buckets": [ - { - "key": "id1", - "1": { - "top": [ - { "sort": [10], "metrics": { "@value": 10, "@anotherValue": 2 } } - ] - } - }, - { - "key": "id2", - "1": { - "top": [ - { "sort": [5], "metrics": { "@value": 5, "@anotherValue": 2 } } - ] - } - } - ] - } - } - }] - }` - - result, err := parseTestResponse(targets, response) - assert.Nil(t, err) - assert.Len(t, result.Responses, 1) - frames := result.Responses["A"].Frames - require.Len(t, frames, 1) - requireFrameLength(t, frames[0], 2) - require.Len(t, frames[0].Fields, 3) - - f1 := frames[0].Fields[0] - f2 := frames[0].Fields[1] - f3 := frames[0].Fields[2] - - require.Equal(t, "id", f1.Name) - require.Equal(t, "Top Metrics @value", f2.Name) - require.Equal(t, "Top Metrics @anotherValue", f3.Name) - - requireStringAt(t, "id1", f1, 0) - requireStringAt(t, "id2", f1, 1) - - requireFloatAt(t, 10, f2, 0) - requireFloatAt(t, 5, f2, 1) - - requireFloatAt(t, 2, f3, 0) - requireFloatAt(t, 2, f3, 1) - }) - - t.Run("With max and multiple terms agg", func(t *testing.T) { - targets := map[string]string{ - "A": `{ - "metrics": [ - { - "type": "max", - "field": "counter", - "id": "1" - } - ], - "bucketAggs": [{ "type": "terms", "field": "label", "id": "2" }, { "type": "terms", "field": "level", "id": "3" }] - }`, - } - response := `{ - "responses": [{ - "aggregations": { - "2": { - "buckets": [ - { - "key": "val3", - "3": { - "buckets": [ - { "key": "info", "1": { "value": "299" } }, { "key": "error", "1": {"value": "300"} } - ] - } - }, - { - "key": "val2", - "3": { - "buckets": [ - {"key": "info", "1": {"value": "300"}}, {"key": "error", "1": {"value": "298"} } - ] - } - }, - { - "key": "val1", - "3": { - "buckets": [ - {"key": "info", "1": {"value": "299"}}, {"key": "error", "1": {"value": "296"} } - ] - } - } - ] - } - } - }] - }` - - result, err := parseTestResponse(targets, response) - assert.Nil(t, err) - assert.Len(t, result.Responses, 1) - frames := result.Responses["A"].Frames - require.Len(t, frames, 1) - requireFrameLength(t, frames[0], 6) - require.Len(t, frames[0].Fields, 3) - - f1 := frames[0].Fields[0] - f2 := frames[0].Fields[1] - f3 := frames[0].Fields[2] - - require.Equal(t, "label", f1.Name) - require.Equal(t, "level", f2.Name) - require.Equal(t, "Max", f3.Name) - - requireStringAt(t, "val3", f1, 0) - requireStringAt(t, "val3", f1, 1) - requireStringAt(t, "val2", f1, 2) - requireStringAt(t, "val2", f1, 3) - requireStringAt(t, "val1", f1, 4) - requireStringAt(t, "val1", f1, 5) - - requireStringAt(t, "info", f2, 0) - requireStringAt(t, "error", f2, 1) - requireStringAt(t, "info", f2, 2) - requireStringAt(t, "error", f2, 3) - requireStringAt(t, "info", f2, 4) - requireStringAt(t, "error", f2, 5) - - requireFloatAt(t, 299, f3, 0) - requireFloatAt(t, 300, f3, 1) - requireFloatAt(t, 300, f3, 2) - requireFloatAt(t, 298, f3, 3) - requireFloatAt(t, 299, f3, 4) - requireFloatAt(t, 296, f3, 5) + requireNumberValue(t, 3, frames[0], 1) + requireNumberValue(t, 4, frames[1], 1) + requireNumberValue(t, 12, frames[2], 1) + }) }) } -func parseTestResponse(tsdbQueries map[string]string, responseBody string) (*backend.QueryDataResponse, error) { - from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) - to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) - configuredFields := es.ConfiguredFields{ - TimeField: "@timestamp", - LogMessageField: "line", - LogLevelField: "lvl", - } - timeRange := backend.TimeRange{ - From: from, - To: to, - } - tsdbQuery := backend.QueryDataRequest{ - Queries: []backend.DataQuery{}, - } +func TestParseResponse(t *testing.T) { + t.Run("Correctly matches refId to response", func(t *testing.T) { + require.NoError(t, nil) + query := []byte(` + [ + { + "refId": "COUNT_GROUPBY_DATE_HISTOGRAM", + "metrics": [{ "type": "count", "id": "c_1" }], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "c_2" }] + }, + { + "refId": "COUNT_GROUPBY_HISTOGRAM", + "metrics": [{ "type": "count", "id": "h_3" }], + "bucketAggs": [{ "type": "histogram", "field": "bytes", "id": "h_4" }] + }, + { + "refId": "RAW_DOC", + "metrics": [{ "type": "raw_document", "id": "r_5" }], + "bucketAggs": [] + }, + { + "refId": "PERCENTILE", + "metrics": [ + { + "type": "percentiles", + "settings": { "percents": ["75", "90"] }, + "id": "p_1" + } + ], + "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "p_3" }] + }, + { + "refId": "EXTENDEDSTATS", + "metrics": [ + { + "type": "extended_stats", + "meta": { "max": true, "std_deviation_bounds_upper": true }, + "id": "e_1" + } + ], + "bucketAggs": [ + { "type": "terms", "field": "host", "id": "e_3" }, + { "type": "date_histogram", "id": "e_4" } + ] + }, + { + "refId": "RAWDATA", + "metrics": [{ "type": "raw_data", "id": "6" }], + "bucketAggs": [] + } + ] + `) - for refID, tsdbQueryBody := range tsdbQueries { - tsdbQuery.Queries = append(tsdbQuery.Queries, backend.DataQuery{ - TimeRange: timeRange, - RefID: refID, - JSON: json.RawMessage(tsdbQueryBody), - }) - } + response := []byte(` + { + "responses": [ + { + "aggregations": { + "c_2": { + "buckets": [{"doc_count": 10, "key": 1000}] + } + } + }, + { + "aggregations": { + "h_4": { + "buckets": [{ "doc_count": 1, "key": 1000 }] + } + } + }, + { + "hits": { + "total": 2, + "hits": [ + { + "_id": "5", + "_type": "type", + "_index": "index", + "_source": { "sourceProp": "asd" }, + "fields": { "fieldProp": "field" } + }, + { + "_source": { "sourceProp": "asd2" }, + "fields": { "fieldProp": "field2" } + } + ] + } + }, + { + "aggregations": { + "p_3": { + "buckets": [ + { + "p_1": { "values": { "75": 3.3, "90": 5.5 } }, + "doc_count": 10, + "key": 1000 + }, + { + "p_1": { "values": { "75": 2.3, "90": 4.5 } }, + "doc_count": 15, + "key": 2000 + } + ] + } + } + }, + { + "aggregations": { + "e_3": { + "buckets": [ + { + "key": "server1", + "e_4": { + "buckets": [ + { + "e_1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + } + }, + { + "key": "server2", + "e_4": { + "buckets": [ + { + "e_1": { + "max": 10.2, + "min": 5.5, + "std_deviation_bounds": { "upper": 3, "lower": -2 } + }, + "doc_count": 10, + "key": 1000 + } + ] + } + } + ] + } + } + }, + { + "hits": { + "total": { + "relation": "eq", + "value": 1 + }, + "hits": [ + { + "_id": "6", + "_type": "_doc", + "_index": "index", + "_source": { "sourceProp": "asd" } + } + ] + } + } + ] + } + `) - var response es.MultiSearchResponse - err := json.Unmarshal([]byte(responseBody), &response) - if err != nil { - return nil, err - } + result, err := queryDataTest(query, response) + require.NoError(t, err) - queries, err := parseQuery(tsdbQuery.Queries) - if err != nil { - return nil, err - } + verifyFrames := func(name string, expectedLength int) { + r, found := result.response.Responses[name] + require.True(t, found, "not found: "+name) + require.NoError(t, r.Error) + require.Len(t, r.Frames, expectedLength, "length wrong for "+name) + } - return parseResponse(response.Responses, queries, configuredFields) + verifyFrames("COUNT_GROUPBY_DATE_HISTOGRAM", 1) + verifyFrames("COUNT_GROUPBY_HISTOGRAM", 1) + verifyFrames("RAW_DOC", 1) + verifyFrames("PERCENTILE", 2) + verifyFrames("EXTENDEDSTATS", 4) + verifyFrames("RAWDATA", 1) + }) } func TestLabelOrderInFieldName(t *testing.T) { @@ -1957,3 +3316,156 @@ func TestFlatten(t *testing.T) { require.Equal(t, map[string]interface{}{"nested11": map[string]interface{}{"nested12": "abc"}}, flattened["nested0.nested1.nested2.nested3.nested4.nested5.nested6.nested7.nested8.nested9.nested10"]) }) } + +func TestTrimEdges(t *testing.T) { + query := []byte(` + [ + { + "refId": "A", + "metrics": [ + { "type": "avg", "id": "1", "field": "@value" }, + { "type": "count", "id": "3" } + ], + "bucketAggs": [ + { + "id": "2", + "type": "date_histogram", + "field": "host", + "settings": { "trimEdges": "1" } + } + ] + } + ] + `) + + response := []byte(` + { + "responses": [ + { + "aggregations": { + "2": { + "buckets": [ + { "1": { "value": 1000 }, "key": 1, "doc_count": 369 }, + { "1": { "value": 2000 }, "key": 2, "doc_count": 200 }, + { "1": { "value": 2000 }, "key": 3, "doc_count": 200 } + ] + } + } + } + ] + } + `) + + result, err := queryDataTest(query, response) + require.NoError(t, err) + + require.Len(t, result.response.Responses, 1) + frames := result.response.Responses["A"].Frames + require.Len(t, frames, 2) + + // should remove first and last value + requireFrameLength(t, frames[0], 1) +} + +func parseTestResponse(tsdbQueries map[string]string, responseBody string) (*backend.QueryDataResponse, error) { + from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) + to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) + configuredFields := es.ConfiguredFields{ + TimeField: "@timestamp", + LogMessageField: "line", + LogLevelField: "lvl", + } + timeRange := backend.TimeRange{ + From: from, + To: to, + } + tsdbQuery := backend.QueryDataRequest{ + Queries: []backend.DataQuery{}, + } + + for refID, tsdbQueryBody := range tsdbQueries { + tsdbQuery.Queries = append(tsdbQuery.Queries, backend.DataQuery{ + TimeRange: timeRange, + RefID: refID, + JSON: json.RawMessage(tsdbQueryBody), + }) + } + + var response es.MultiSearchResponse + err := json.Unmarshal([]byte(responseBody), &response) + if err != nil { + return nil, err + } + + queries, err := parseQuery(tsdbQuery.Queries) + if err != nil { + return nil, err + } + + return parseResponse(response.Responses, queries, configuredFields) +} + +func requireTimeValue(t *testing.T, expected int64, frame *data.Frame, index int) { + getField := func() *data.Field { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeTime { + return field + } + } + return nil + } + + field := getField() + require.NotNil(t, field, "missing time-field") + + require.Equal(t, time.UnixMilli(expected).UTC(), field.At(index), fmt.Sprintf("wrong time at index %v", index)) +} + +func requireNumberValue(t *testing.T, expected float64, frame *data.Frame, index int) { + getField := func() *data.Field { + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeNullableFloat64 { + return field + } + } + return nil + } + + field := getField() + require.NotNil(t, field, "missing number-field") + + v := field.At(index).(*float64) + + require.Equal(t, expected, *v, fmt.Sprintf("wrong number at index %v", index)) +} + +func requireFrameLength(t *testing.T, frame *data.Frame, expectedLength int) { + l, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, expectedLength, l, "wrong frame-length") +} + +func requireStringAt(t *testing.T, expected string, field *data.Field, index int) { + v := field.At(index).(*string) + require.Equal(t, expected, *v, fmt.Sprintf("wrong string at index %v", index)) +} + +func requireFloatAt(t *testing.T, expected float64, field *data.Field, index int) { + v := field.At(index).(*float64) + require.Equal(t, expected, *v, fmt.Sprintf("wrong flaot at index %v", index)) +} + +func requireTimeSeriesName(t *testing.T, expected string, frame *data.Frame) { + getField := func() *data.Field { + for _, field := range frame.Fields { + if field.Type() != data.FieldTypeTime { + return field + } + } + return nil + } + + field := getField() + require.NotNil(t, expected, field.Config) + require.Equal(t, expected, field.Config.DisplayNameFromDS) +} From d7bd06a87ea73e6dddd3092465a1bdb0d4598dfa Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 26 Apr 2023 12:10:55 -0500 Subject: [PATCH 438/729] Datasources: Add documentation around secure socks proxy (#66609) --------- Co-authored-by: Mitch Seaman Co-authored-by: Chris Moyer --- .../configure-grafana/proxy/index.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/sources/setup-grafana/configure-grafana/proxy/index.md diff --git a/docs/sources/setup-grafana/configure-grafana/proxy/index.md b/docs/sources/setup-grafana/configure-grafana/proxy/index.md new file mode 100644 index 00000000000..a1d973a3d82 --- /dev/null +++ b/docs/sources/setup-grafana/configure-grafana/proxy/index.md @@ -0,0 +1,44 @@ +--- +description: Learn about proxy datasource connections through a secure socks proxy. +keywords: + - proxy + - guide + - Grafana +title: Configure a data source connection proxy +menuTitle: Configure data source proxy +weight: 1110 +--- + +# Configure a data source connection proxy + +Grafana provides support for proxying data source connections through a Secure Socks5 Tunnel. This enables you to securely connect to data sources hosted in a different network than Grafana. + +To make use of this functionality, you need to deploy a socks5 proxy server that supports TLS on a machine exposed to the public internet within the same network as your data source. From there, Grafana establishes a mutually trusted connection from Grafana to the Proxy. Then the Proxy can proxy the Grafana connection to your private server without exposing your data sources to the public internet. + +## Known limitations + +- You can configure only one socks5 proxy per Grafana instance +- All built-in core data sources are compatible, but not all external data sources are. For a list of supported data sources, refer to [private data source connect]({{< ref "/docs/grafana-cloud/data-configuration/configure-private-datasource-connect/#known-limitations" >}}). + +## Before you begin + +To complete this task, you must first deploy a socks proxy server that supports TLS, is publicly accessible, and is hosted within the same network as the data source. + +## Steps + +1. For Grafana to send data source connections to the socks5 server, use the following table to configure the `secure_socks_datasource_proxy` section of the `config.ini`: + + | Key | Description | Example | + | --------------- | ------------------------------------------ | ------------------------------- | + | `enabled` | Enable this feature in Grafana | true | + | `root_ca_cert` | The file path of the root ca cert | /etc/ca.crt | + | `client_key` | The file path of the client private key | /etc/client.key | + | `client_cert` | The file path of the client public key | /etc/client.crt | + | `server_name` | The domain name of the proxy, used for SNI | proxy.grafana.svc.cluster.local | + | `proxy_address` | the address of the proxy | localhost:9090 | + +1. Set up a data source and configure it to send data source connections through the proxy. + + To configure your data sources to send connections through the proxy, `enableSecureSocksProxy=true` must be specified in the data source json. You can do this in the [API]({{< relref "../../../developers/http_api/data_source" >}}) or use [file based provisioning]({{< relref "../../../administration/provisioning/#data-sources" >}}). + + Additionally, you can set the socks5 username and password by adding `secureSocksProxyUsername` in the data source json and `secureSocksProxyPassword` in the secure data source json. From 3fc796dfe603bc8581d631e09163a84268e9e30c Mon Sep 17 00:00:00 2001 From: Kevin Minehart Date: Wed, 26 Apr 2023 13:18:03 -0500 Subject: [PATCH 439/729] Docs: Update references of `grafana-cli` to `grafana cli` and `grafana-server` to `grafana server`. (#66981) * Update grafana-cli to grafana cli in relevant docs/sources * Update relevant docs to use 'grafana server' instead of 'grafana-server' --- docs/sources/cli.md | 52 +++++++++---------- .../configure-tracing/index.md | 6 +-- .../configure-database-encryption/_index.md | 6 +-- .../encrypt-secrets-using-aws-kms/index.md | 2 +- .../index.md | 2 +- .../index.md | 2 +- .../index.md | 2 +- .../installation/docker/index.md | 2 +- .../setup-grafana/installation/mac/index.md | 2 +- .../setup-grafana/start-restart-grafana.md | 10 ++-- .../shared/upgrade/upgrade-common-tasks.md | 2 +- 11 files changed, 44 insertions(+), 44 deletions(-) diff --git a/docs/sources/cli.md b/docs/sources/cli.md index 3e030da5fc1..622b72906cd 100644 --- a/docs/sources/cli.md +++ b/docs/sources/cli.md @@ -1,11 +1,11 @@ --- aliases: - administration/cli/ -description: Guide to using grafana-cli +description: Guide to using grafana cli keywords: - grafana - cli - - grafana-cli + - grafana cli - command line interface title: Grafana CLI weight: 400 @@ -18,12 +18,12 @@ Grafana CLI is a small executable that is bundled with Grafana server. It can be To list all commands and options: ``` -grafana-cli -h +grafana cli -h ``` ## Invoking Grafana CLI -To invoke Grafana CLI, add the path to the grafana binaries in your `PATH` environment variable. Alternately, if your current directory is the `bin` directory, use `./grafana-cli`. Otherwise, you can specify full path to the CLI. For example, on Linux `/usr/share/grafana/bin/grafana-cli` and on Windows `C:\Program Files\GrafanaLabs\grafana\bin\grafana-cli.exe`. +To invoke Grafana CLI, add the path to the grafana binaries in your `PATH` environment variable. Alternately, if your current directory is the `bin` directory, use `./grafana cli`. Otherwise, you can specify full path to the CLI. For example, on Linux `/usr/share/grafana/bin/grafana` and on Windows `C:\Program Files\GrafanaLabs\grafana\bin\grafana.exe`, and invoke it with `grafana cli`. > **Note:** Some commands, such as installing or removing plugins, require `sudo` on Linux. If you are on Windows, run Windows PowerShell as Administrator. @@ -32,7 +32,7 @@ To invoke Grafana CLI, add the path to the grafana binaries in your `PATH` envir The general syntax for commands in Grafana CLI is: ```bash -grafana-cli [global options] command [command options] [arguments...] +grafana cli [global options] command [command options] [arguments...] ``` ## Global options @@ -48,7 +48,7 @@ Each global option applies only to the command in which it is used. For example, **Example:** ```bash -grafana-cli -h +grafana cli -h ``` ### Display Grafana CLI version @@ -58,7 +58,7 @@ grafana-cli -h **Example:** ```bash -grafana-cli -v +grafana cli -v ``` ### Override default plugin directory @@ -68,7 +68,7 @@ grafana-cli -v **Example:** ```bash -grafana-cli --pluginsDir "/var/lib/grafana/devplugins" plugins install +grafana cli --pluginsDir "/var/lib/grafana/devplugins" plugins install ``` ### Override default plugin repo URL @@ -78,7 +78,7 @@ grafana-cli --pluginsDir "/var/lib/grafana/devplugins" plugins install +grafana cli --repo "https://example.com/plugins" plugins install ``` ### Override default plugin .zip URL @@ -88,7 +88,7 @@ grafana-cli --repo "https://example.com/plugins" plugins install **Example:** ```bash -grafana-cli --pluginUrl https://company.com/grafana/plugins/-.zip plugins install +grafana cli --pluginUrl https://company.com/grafana/plugins/-.zip plugins install ``` ### Override Transport Layer Security @@ -100,7 +100,7 @@ grafana-cli --pluginUrl https://company.com/grafana/plugins/--.zip plugins install +grafana cli --insecure --pluginUrl https://company.com/grafana/plugins/-.zip plugins install ``` ### Enable debug logging @@ -110,7 +110,7 @@ grafana-cli --insecure --pluginUrl https://company.com/grafana/plugins/ +grafana cli --debug plugins install ``` ### Override a configuration setting @@ -122,7 +122,7 @@ For example, you can use it to redirect logging to another file (maybe to log pl **Example:** ```bash -grafana-cli --configOverrides cfg:default.paths.log=/dev/null plugins install +grafana cli --configOverrides cfg:default.paths.log=/dev/null plugins install ``` ### Override homepath value @@ -132,7 +132,7 @@ Sets the path for the Grafana install/home path, defaults to working directory. **Example:** ```bash -grafana-cli --homepath "/usr/share/grafana" admin reset-admin-password +grafana cli --homepath "/usr/share/grafana" admin reset-admin-password ``` ### Override config file @@ -142,7 +142,7 @@ grafana-cli --homepath "/usr/share/grafana" admin reset-admin-password +grafana cli plugins install ``` ### Install a specific version of a plugin ```bash -grafana-cli plugins install +grafana cli plugins install ``` ### List installed plugins ```bash -grafana-cli plugins ls +grafana cli plugins ls ``` ### Update all installed plugins ```bash -grafana-cli plugins update-all +grafana cli plugins update-all ``` ### Update one plugin ```bash -grafana-cli plugins update +grafana cli plugins update ``` ### Remove one plugin ```bash -grafana-cli plugins remove +grafana cli plugins remove ``` ## Admin commands @@ -200,12 +200,12 @@ Admin commands are only available in Grafana 4.1 and later. ### Show all admin commands ```bash -grafana-cli admin +grafana cli admin ``` ### Reset admin password -`grafana-cli admin reset-admin-password ` resets the password for the admin user using the CLI. You might need to do this if you lose the admin password. +`grafana cli admin reset-admin-password ` resets the password for the admin user using the CLI. You might need to do this if you lose the admin password. If there are two flags being used to set the homepath and the config file path, then running the command returns this error: @@ -214,7 +214,7 @@ If there are two flags being used to set the homepath and the config file path, To correct this, use the `--homepath` global option to specify the Grafana default homepath for this command: ```bash -grafana-cli --homepath "/usr/share/grafana" admin reset-admin-password +grafana cli --homepath "/usr/share/grafana" admin reset-admin-password ``` If you have not lost the admin password, we recommend that you change the user password either in the User Preferences or in the Server Admin > User tab. @@ -230,5 +230,5 @@ If you need to set the password in a script, then you can use the [Grafana User **Example:** ```bash -grafana-cli admin data-migration encrypt-datasource-passwords +grafana cli admin data-migration encrypt-datasource-passwords ``` diff --git a/docs/sources/setup-grafana/configure-grafana/configure-tracing/index.md b/docs/sources/setup-grafana/configure-grafana/configure-tracing/index.md index a89c05a815f..2a1bb76614d 100644 --- a/docs/sources/setup-grafana/configure-grafana/configure-tracing/index.md +++ b/docs/sources/setup-grafana/configure-grafana/configure-tracing/index.md @@ -9,7 +9,7 @@ weight: 200 # Configure tracing to troubleshoot Grafana -You can set up the `grafana-server` process to enable certain diagnostics when it starts. This can be helpful +You can set up the `grafana` server process to enable certain diagnostics when it starts. This can be helpful when investigating certain performance problems. It's _not_ recommended to have these enabled by default. ## Turn on profiling @@ -18,7 +18,7 @@ The `grafana-server` can be started with the arguments `-profile` to enable prof `-profile-port` to override the default HTTP port (`6060`) where the `pprof` debugging endpoints are available. For example: ```bash -./grafana-server -profile -profile-addr=0.0.0.0 -profile-port=8080 +./grafana server -profile -profile-addr=0.0.0.0 -profile-port=8080 ``` Note that `pprof` debugging endpoints are served on a different port than the Grafana HTTP server. @@ -38,7 +38,7 @@ Refer to [Go command pprof](https://golang.org/cmd/pprof/) for more information The `grafana-server` can be started with the arguments `-tracing` to enable tracing and `-tracing-file` to override the default trace file (`trace.out`) where trace result is written to. For example: ```bash -./grafana-server -tracing -tracing-file=/tmp/trace.out +./grafana server -tracing -tracing-file=/tmp/trace.out ``` You can configure or override profiling settings using environment variables: diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/_index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/_index.md index 354df10bdb0..5384904b7af 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/_index.md @@ -49,19 +49,19 @@ You can re-encrypt secrets in order to: - Move already existing secrets' encryption forward from legacy to envelope encryption. - Re-encrypt secrets after a [data keys rotation](#rotate-data-keys). -To re-encrypt secrets, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana-cli admin secrets-migration re-encrypt` command or the `/encryption/reencrypt-secrets` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#roll-back-secrets" >}}). It's safe to run more than once, more recommended under maintenance mode. +To re-encrypt secrets, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana cli admin secrets-migration re-encrypt` command or the `/encryption/reencrypt-secrets` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#roll-back-secrets" >}}). It's safe to run more than once, more recommended under maintenance mode. ### Roll back secrets You can roll back secrets encrypted with envelope encryption to legacy encryption. This might be necessary to downgrade to Grafana versions prior to v9.0 after an unsuccessful upgrade. -To roll back secrets, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana-cli admin secrets-migration rollback` command or the `/encryption/rollback-secrets` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#re-encrypt-secrets" >}}). It's safe to run more than once, more recommended under maintenance mode. +To roll back secrets, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana cli admin secrets-migration rollback` command or the `/encryption/rollback-secrets` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#re-encrypt-secrets" >}}). It's safe to run more than once, more recommended under maintenance mode. ### Re-encrypt data keys You can re-encrypt data keys encrypted with a specific key encryption key (KEK). This allows you to either re-encrypt existing data keys with a new KEK version (see [KMS integration](#kms-integration) rotation) or to re-encrypt them with a completely different KEK. -To re-encrypt data keys, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana-cli admin secrets-migration re-encrypt-data-keys` command or the `/encryption/reencrypt-data-keys` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#re-encrypt-data-encryption-keys" >}}). It's safe to run more than once, more recommended under maintenance mode. +To re-encrypt data keys, use the [Grafana CLI]({{< relref "../../../cli/" >}}) by running the `grafana cli admin secrets-migration re-encrypt-data-keys` command or the `/encryption/reencrypt-data-keys` endpoint of the Grafana [Admin API]({{< relref "../../../developers/http_api/admin/#re-encrypt-data-encryption-keys" >}}). It's safe to run more than once, more recommended under maintenance mode. ### Rotate data keys diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md index 126db3aca5e..699694e1142 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-aws-kms/index.md @@ -75,7 +75,7 @@ You can use an encryption key from AWS Key Management Service to encrypt secrets 8. (Optional) From the command line and the root directory of Grafana, re-encrypt all of the secrets within the Grafana database with the new key using the following command: - `grafana-cli admin secrets-migration re-encrypt` + `grafana cli admin secrets-migration re-encrypt` If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md index 23067f55776..431745e0e66 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-azure-key-vault/index.md @@ -73,7 +73,7 @@ You can use an encryption key from Azure Key Vault to encrypt secrets in the Gra 10. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: - `grafana-cli admin secrets-migration re-encrypt` + `grafana cli admin secrets-migration re-encrypt` If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md index 4d8ae318285..325e4a72df4 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-google-cloud-kms/index.md @@ -62,7 +62,7 @@ You can use an encryption key from Google Cloud Key Management Service to encryp 9. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: - `grafana-cli admin secrets-migration re-encrypt` + `grafana cli admin secrets-migration re-encrypt` If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. diff --git a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md index 0872fbdff35..dd5a1edb504 100644 --- a/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/index.md @@ -69,7 +69,7 @@ You can use an encryption key from Hashicorp Vault to encrypt secrets in the Gra 8. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: - `grafana-cli admin secrets-migration re-encrypt` + `grafana cli admin secrets-migration re-encrypt` If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. diff --git a/docs/sources/setup-grafana/installation/docker/index.md b/docs/sources/setup-grafana/installation/docker/index.md index 9a98d0a37c6..59477b2b7da 100644 --- a/docs/sources/setup-grafana/installation/docker/index.md +++ b/docs/sources/setup-grafana/installation/docker/index.md @@ -80,7 +80,7 @@ You can install official and community plugins listed on the Grafana [plugins pa ### Install official and community Grafana plugins -Pass the plugins you want installed to Docker with the `GF_INSTALL_PLUGINS` environment variable as a comma-separated list. This sends each plugin name to `grafana-cli plugins install ${plugin}` and installs them when Grafana starts. +Pass the plugins you want installed to Docker with the `GF_INSTALL_PLUGINS` environment variable as a comma-separated list. This sends each plugin name to `grafana cli plugins install ${plugin}` and installs them when Grafana starts. ```bash docker run -d \ diff --git a/docs/sources/setup-grafana/installation/mac/index.md b/docs/sources/setup-grafana/installation/mac/index.md index 71b8c46828c..eb5602e9bf7 100644 --- a/docs/sources/setup-grafana/installation/mac/index.md +++ b/docs/sources/setup-grafana/installation/mac/index.md @@ -54,7 +54,7 @@ To install Grafana on macOS using the standalone binaries, complete the followin 1. To start Grafana service, go to the directory and run the command: ```bash - ./bin/grafana-server + ./bin/grafana server ``` ## Next steps diff --git a/docs/sources/setup-grafana/start-restart-grafana.md b/docs/sources/setup-grafana/start-restart-grafana.md index 578752c109b..b74a92790af 100644 --- a/docs/sources/setup-grafana/start-restart-grafana.md +++ b/docs/sources/setup-grafana/start-restart-grafana.md @@ -95,12 +95,12 @@ sudo service grafana-server restart ### Start the server using the binary -The `grafana-server` binary .tar.gz needs the working directory to be the root install directory where the binary and the `public` folder are located. +The `grafana` binary .tar.gz needs the working directory to be the root install directory where the binary and the `public` folder are located. To start the Grafana server, run the following command: ```bash -./bin/grafana-server +./bin/grafana server ``` ## Docker @@ -139,9 +139,9 @@ To restart the running container, use this command: Complete the following steps to start the Grafana server on Windows: -1. Execute `grafana-server.exe`, which is located in the `bin` directory. +1. Execute `grafana.exe server`; the `grafana` binary is located in the `bin` directory. - We recommend that you run `grafana-server.exe` from the command line. + We recommend that you run `grafana.exe server` from the command line. If you want to run Grafana as a Windows service, you can download [NSSM](https://nssm.cc/). @@ -183,7 +183,7 @@ To restart Grafana: 1. Run the command: ```bash -./bin/grafana-server +./bin/grafana server ``` ## Next steps diff --git a/docs/sources/shared/upgrade/upgrade-common-tasks.md b/docs/sources/shared/upgrade/upgrade-common-tasks.md index bd964412efe..45d2428a466 100644 --- a/docs/sources/shared/upgrade/upgrade-common-tasks.md +++ b/docs/sources/shared/upgrade/upgrade-common-tasks.md @@ -183,5 +183,5 @@ can make older plugins stop working properly. Run the following command to update plugins: ```bash -grafana-cli plugins update-all +grafana cli plugins update-all ``` From b71ef9b6670c18f8e93cafc5d0ac97d08818239e Mon Sep 17 00:00:00 2001 From: George Robinson Date: Wed, 26 Apr 2023 20:51:55 +0100 Subject: [PATCH 440/729] Alerting: Update grafana/alerting to fix #67177 (#67324) --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 973665796c3..a950fbf387a 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.5.0 - github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32 + github.com/grafana/alerting v0.0.0-20230426173942-011a41e1fbe2 github.com/grafana/grafana-aws-sdk v0.12.0 github.com/grafana/grafana-azure-sdk-go v1.6.0 github.com/grafana/grafana-plugin-sdk-go v0.159.0 diff --git a/go.sum b/go.sum index afe2f78b780..9d33a7965f4 100644 --- a/go.sum +++ b/go.sum @@ -1274,6 +1274,8 @@ github.com/grafana/alerting v0.0.0-20230410151633-4a7ecc241d72 h1:WuQGIUeDIyPviy github.com/grafana/alerting v0.0.0-20230410151633-4a7ecc241d72/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32 h1:LdPoVBj+CA5oHLeUejDzqy8/c4Fa0UfTtCcOHka0Jws= github.com/grafana/alerting v0.0.0-20230418161049-5f374e58cb32/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= +github.com/grafana/alerting v0.0.0-20230426173942-011a41e1fbe2 h1:teRmmE08bSnvyh3e+adfv/6RA1ZZdhTCmNL9Ckfm1Rk= +github.com/grafana/alerting v0.0.0-20230426173942-011a41e1fbe2/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= 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.8 h1:l0AKXfHr0clu6qPirirDzNC/W5mqq5gG7iruOVolG34= From d80d984b527b6ff66f96ff921d8635107e9cc7d8 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Wed, 26 Apr 2023 22:21:41 +0200 Subject: [PATCH 441/729] chore: add smokescreen e2e test for all panels (#66230) --- .../panels_smokescreen.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 e2e/smoke-tests-suite/panels_smokescreen.spec.ts diff --git a/e2e/smoke-tests-suite/panels_smokescreen.spec.ts b/e2e/smoke-tests-suite/panels_smokescreen.spec.ts new file mode 100644 index 00000000000..49a1db6687d --- /dev/null +++ b/e2e/smoke-tests-suite/panels_smokescreen.spec.ts @@ -0,0 +1,45 @@ +import { e2e } from '@grafana/e2e'; +import { GrafanaBootConfig } from '@grafana/runtime'; + +e2e.scenario({ + describeName: 'Panels smokescreen', + itName: 'Tests each panel type in the panel edit view to ensure no crash', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.addDashboard(); + + // TODO: Try and use e2e.flows.addPanel() instead of block below + try { + e2e.components.PageToolbar.itemButton('Add panel button').should('be.visible'); + e2e.components.PageToolbar.itemButton('Add panel button').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 button').last().click(); + } + e2e.pages.AddDashboard.itemButton('Add new visualization menu item').should('be.visible'); + e2e.pages.AddDashboard.itemButton('Add new visualization menu item').click(); + + e2e() + .window() + .then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => { + // Loop through every panel type and ensure no crash + Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => { + // TODO: Remove Flame Graph check as part of addressing #66803 + if (!panel.hideFromList && panel.state !== 'deprecated' && panel.name !== 'Flame Graph') { + e2e.components.PanelEditor.toggleVizPicker().click(); + e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click(); + + // Wait for panel to load (TODO: Better way to do this?) + cy.wait(500); + + e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain(panel.name)); + // TODO: Come up with better check / better failure messaging to clearly indicate which panel failed + cy.contains('An unexpected error happened').should('not.exist'); + } + }); + }); + }, +}); From 81792a8dceda28695736dc0f27663a2e7f77c74f Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 26 Apr 2023 15:46:18 -0500 Subject: [PATCH 442/729] Timeseries: Migrate legend hideFrom (#67305) --- .../panel/timeseries/migrations.test.ts | 112 +++++++++++++++++- .../plugins/panel/timeseries/migrations.ts | 33 ++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/panel/timeseries/migrations.test.ts b/public/app/plugins/panel/timeseries/migrations.test.ts index 03815e8aa56..4da0458f154 100644 --- a/public/app/plugins/panel/timeseries/migrations.test.ts +++ b/public/app/plugins/panel/timeseries/migrations.test.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash'; -import { PanelModel, FieldConfigSource, FieldMatcherID } from '@grafana/data'; +import { PanelModel, FieldConfigSource, FieldMatcherID, ReducerID } from '@grafana/data'; import { TooltipDisplayMode, SortOrder } from '@grafana/schema'; import { graphPanelChangedHandler } from './migrations'; @@ -144,6 +144,116 @@ describe('Graph Migrations', () => { panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); expect(panel.options.legend.width).toBe(200); }); + + test('hide allZeros', () => { + const old = { + angular: { + legend: { + show: true, + values: false, + min: false, + max: false, + current: false, + total: false, + avg: false, + hideZero: true, + }, + }, + }; + const panel = {} as PanelModel; + panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); + expect(panel.fieldConfig.overrides).toHaveLength(1); + expect(panel.fieldConfig.overrides[0].matcher.options.reducer).toBe(ReducerID.allIsZero); + expect(panel.fieldConfig.overrides).toMatchInlineSnapshot(` + [ + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsZero", + "value": 0, + }, + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": false, + }, + }, + ], + }, + ] + `); + }); + + test('hide allZeros allNulls', () => { + const old = { + angular: { + legend: { + show: true, + values: false, + min: false, + max: false, + current: false, + total: false, + avg: false, + hideEmpty: true, + hideZero: true, + }, + }, + }; + const panel = {} as PanelModel; + panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); + expect(panel.fieldConfig.overrides).toHaveLength(2); + expect(panel.fieldConfig.overrides).toMatchInlineSnapshot(` + [ + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsZero", + "value": 0, + }, + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": false, + }, + }, + ], + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0, + }, + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": false, + }, + }, + ], + }, + ] + `); + }); }); describe('stacking', () => { diff --git a/public/app/plugins/panel/timeseries/migrations.ts b/public/app/plugins/panel/timeseries/migrations.ts index a71d4c22f4a..9f1c774a94c 100644 --- a/public/app/plugins/panel/timeseries/migrations.ts +++ b/public/app/plugins/panel/timeseries/migrations.ts @@ -12,6 +12,7 @@ import { FieldType, NullValueMode, PanelTypeChangedHandler, + ReducerID, Threshold, ThresholdsMode, } from '@grafana/data'; @@ -30,6 +31,7 @@ import { StackingMode, SortOrder, GraphTransform, + ComparisonOperation, } from '@grafana/schema'; import { defaultGraphConfig } from './config'; @@ -350,6 +352,14 @@ export function graphToTimeseriesOptions(angular: any): { fieldConfig: FieldConf if (angular.legend.sideWidth) { options.legend.width = angular.legend.sideWidth; } + + if (legendConfig.hideZero) { + overrides.push(getLegendHideFromOverride(ReducerID.allIsZero)); + } + + if (legendConfig.hideEmpty) { + overrides.push(getLegendHideFromOverride(ReducerID.allIsNull)); + } } const tooltipConfig = angular.tooltip; @@ -614,3 +624,26 @@ function migrateHideFrom(panel: { }); } } + +function getLegendHideFromOverride(reducer: ReducerID.allIsZero | ReducerID.allIsNull) { + return { + matcher: { + id: FieldMatcherID.byValue, + options: { + reducer: reducer, + op: ComparisonOperation.GTE, + value: 0, + }, + }, + properties: [ + { + id: 'custom.hideFrom', + value: { + tooltip: true, + viz: false, + legend: true, + }, + }, + ], + }; +} From fb45cb6237b7653e78f14f780455974939380fab Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Wed, 26 Apr 2023 22:21:29 +0100 Subject: [PATCH 443/729] Explore: Update table min height (#67321) * Set table min content height to 300px * Cleanup code that changes height of table component --- packages/grafana-ui/src/components/Table/Table.tsx | 13 ------------- public/app/features/explore/TableContainer.tsx | 3 +-- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 20ed1419989..2f59657fbd4 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -43,7 +43,6 @@ export const Table = memo((props: Props) => { data, subData, height, - maxHeight, onCellFilterAdded, width, columnMinWidth = COLUMN_MIN_WIDTH, @@ -195,18 +194,6 @@ export const Table = memo((props: Props) => { const pageSize = Math.round(listHeight / tableStyles.rowHeight) - 1; - // Make sure we have room to show the sub-table - const expandedIndices = Object.keys(extendedState.expanded); - if (expandedIndices.length) { - const subTablesHeight = expandedIndices.reduce((sum, index) => { - const subLength = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === parseInt(index, 10))?.length; - return subLength ? sum + tableStyles.rowHeight * (subLength + 1) : sum; - }, 0); - if (listHeight < subTablesHeight) { - listHeight = Math.min(listHeight + subTablesHeight, maxHeight || Number.MAX_SAFE_INTEGER); - } - } - useEffect(() => { // Don't update the page size if it is less than 1 if (pageSize <= 0) { diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 828dbeaebf4..600c4cdd7be 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -46,7 +46,7 @@ export class TableContainer extends PureComponent { } // tries to estimate table height - return Math.max(Math.min(600, mainFrame.length * 36) + 40 + 46); + return Math.min(600, Math.max(mainFrame.length * 36, 300) + 40 + 46); } render() { @@ -103,7 +103,6 @@ export class TableContainer extends PureComponent { subData={subFrames} width={innerWidth} height={innerHeight} - maxHeight={600} onCellFilterAdded={onCellFilterAdded} /> ) : ( From 353e11b7719f01cb31d3349da5e6f3be4c3a5440 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Wed, 26 Apr 2023 23:34:51 +0200 Subject: [PATCH 444/729] Trend: Promote to beta (#67323) --- .../api/plugins/data/expectedListResp.json | 36 +++++++++++++++++++ public/app/plugins/panel/trend/plugin.json | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index e0120dc9bae..a43dfdfadf4 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -1698,6 +1698,42 @@ "signatureType": "", "signatureOrg": "" }, + { + "name": "Trend", + "type": "panel", + "id": "trend", + "enabled": true, + "pinned": false, + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "description": "Like timeseries, but when x != time", + "links": null, + "logos": { + "small": "public/app/plugins/panel/trend/img/trend.svg", + "large": "public/app/plugins/panel/trend/img/trend.svg" + }, + "build": {}, + "screenshots": null, + "version": "", + "updated": "" + }, + "dependencies": { + "grafanaDependency": "", + "grafanaVersion": "*", + "plugins": [] + }, + "latestVersion": "", + "hasUpdate": false, + "defaultNavUrl": "/plugins/trend/", + "category": "", + "state": "beta", + "signature": "internal", + "signatureType": "", + "signatureOrg": "" + }, { "name": "Welcome", "type": "panel", diff --git a/public/app/plugins/panel/trend/plugin.json b/public/app/plugins/panel/trend/plugin.json index 91717e503bd..0433aa43064 100644 --- a/public/app/plugins/panel/trend/plugin.json +++ b/public/app/plugins/panel/trend/plugin.json @@ -3,7 +3,7 @@ "name": "Trend", "id": "trend", - "state": "alpha", + "state": "beta", "info": { "description": "Like timeseries, but when x != time", From 5c4ecf7a866964daf0110a9333c6ac4997202db8 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 26 Apr 2023 15:28:54 -0700 Subject: [PATCH 445/729] Chore: Stop using ArrayVector and MutableField (#67333) --- .../src/dataframe/MutableDataFrame.ts | 7 +++--- .../transformations/transformers/groupBy.ts | 3 +-- .../prometheus/result_transformer.ts | 22 +++++++++---------- .../components/DatagridContextMenu.tsx | 5 ++--- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts index 77abfa58204..94c8a599cde 100644 --- a/packages/grafana-data/src/dataframe/MutableDataFrame.ts +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -7,6 +7,7 @@ import { FunctionalVector } from '../vector/FunctionalVector'; import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from './processDataFrame'; +/** @deprecated */ export type MutableField = Field; type MutableVectorCreator = (buffer?: any[]) => any[]; @@ -65,14 +66,14 @@ export class MutableDataFrame extends FunctionalVector implements Da return this.first.length; } - addFieldFor(value: unknown, name?: string): MutableField { + addFieldFor(value: unknown, name?: string): Field { return this.addField({ name: name || '', // Will be filled in type: guessFieldTypeFromValue(value), }); } - addField(f: Field | FieldDTO, startLength?: number): MutableField { + addField(f: Field | FieldDTO, startLength?: number): Field { let buffer: any[] | undefined = undefined; if (f.values) { @@ -98,7 +99,7 @@ export class MutableDataFrame extends FunctionalVector implements Da name = `Field ${this.fields.length + 1}`; } - const field: MutableField = { + const field: Field = { ...f, name, type, diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts index 03f24ff68b6..5f990c59ec0 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -1,6 +1,5 @@ import { map } from 'rxjs/operators'; -import { MutableField } from '../../dataframe/MutableDataFrame'; import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; import { getFieldDisplayName } from '../../field/fieldState'; import { DataFrame, Field, FieldType } from '../../types/dataFrame'; @@ -63,7 +62,7 @@ export const groupByTransformer: DataTransformerInfo // Group the values by fields and groups so we can get all values for a // group for a given field. - const valuesByGroupKey = new Map>(); + const valuesByGroupKey = new Map>(); for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { const groupKey = String(groupByFields.map((field) => field.values[rowIndex])); const valuesByField = valuesByGroupKey.get(groupKey) ?? {}; diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index 19e63367190..f430483e8c8 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -15,7 +15,6 @@ import { formatLabels, getDisplayProcessor, Labels, - MutableField, PreferredVisualisationType, ScopedVars, TIME_SERIES_TIME_FIELD_NAME, @@ -208,7 +207,7 @@ export function transformDFToTable(dfs: DataFrame[]): DataFrame[] { const valueText = getValueText(refIds.length, refId); const valueField = getValueField({ data: [], valueName: valueText }); const timeField = getTimeField([]); - const labelFields: MutableField[] = []; + const labelFields: Field[] = []; // Fill labelsFields with labels from dataFrames dataFramesByRefId[refId].forEach((df) => { @@ -329,14 +328,13 @@ export function transform( // Return early if result type is scalar if (prometheusResult.resultType === 'scalar') { - return [ - { - meta: options.meta, - refId: options.refId, - length: 1, - fields: [getTimeField([prometheusResult.result]), getValueField({ data: [prometheusResult.result] })], - }, - ]; + const df: DataFrame = { + meta: options.meta, + refId: options.refId, + length: 1, + fields: [getTimeField([prometheusResult.result]), getValueField({ data: [prometheusResult.result] })], + }; + return [df]; } // Return early again if the format is table, this needs special transformation. @@ -556,7 +554,7 @@ function getLabelValue(metric: PromMetric, label: string): string | number { return ''; } -function getTimeField(data: PromValue[], isMs = false): MutableField { +function getTimeField(data: PromValue[], isMs = false): Field { return { name: TIME_SERIES_TIME_FIELD_NAME, type: FieldType.time, @@ -579,7 +577,7 @@ function getValueField({ parseValue = true, labels, displayNameFromDS, -}: ValueFieldOptions): MutableField { +}: ValueFieldOptions): Field { return { name: valueName, type: FieldType.number, diff --git a/public/app/plugins/panel/datagrid/components/DatagridContextMenu.tsx b/public/app/plugins/panel/datagrid/components/DatagridContextMenu.tsx index 2189a3a1693..648fbd45160 100644 --- a/public/app/plugins/panel/datagrid/components/DatagridContextMenu.tsx +++ b/public/app/plugins/panel/datagrid/components/DatagridContextMenu.tsx @@ -2,7 +2,7 @@ import { GridSelection } from '@glideapps/glide-data-grid'; import { capitalize } from 'lodash'; import React from 'react'; -import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; +import { DataFrame, FieldType } from '@grafana/data'; import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType'; import { ContextMenu, MenuGroup, MenuItem } from '@grafana/ui'; import { MenuDivider } from '@grafana/ui/src/components/Menu/MenuDivider'; @@ -111,8 +111,7 @@ export const DatagridContextMenu = ({ label="Clear column" onClick={() => { const field = data.fields[column]; - field.values = new ArrayVector(field.values.toArray().map(() => null)); - + field.values = field.values.map(() => null); saveData({ ...data, }); From 2beee35567e69adc2c5bd287650942cb8ecb763b Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 26 Apr 2023 21:28:58 -0500 Subject: [PATCH 446/729] Timeseries: Time regions migration (#66998) Co-authored-by: Ryan McKinley --- .betterer.results | 23 ++--- e2e/various-suite/graph-auto-migrate.spec.ts | 52 +++++++++++ .../src/selectors/pages.ts | 1 + packages/grafana-runtime/src/config.ts | 2 +- public/app/core/components/Page/Page.tsx | 3 +- .../app/plugins/panel/graph/tab_display.html | 2 +- .../__snapshots__/migrations.test.ts.snap | 31 +++++++ .../panel/timeseries/migrations.test.ts | 45 ++++++++++ .../plugins/panel/timeseries/migrations.ts | 90 ++++++++++++++++++- 9 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 e2e/various-suite/graph-auto-migrate.spec.ts diff --git a/.betterer.results b/.betterer.results index 449693d7e46..e7f154cafba 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5922,20 +5922,23 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "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.", "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.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"], + [0, 0, 0, "Unexpected any. Specify a different type.", "12"], + [0, 0, 0, "Do not use any type assertions.", "13"], [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"] + [0, 0, 0, "Do not use any type assertions.", "15"], + [0, 0, 0, "Unexpected any. Specify a different type.", "16"], + [0, 0, 0, "Unexpected any. Specify a different type.", "17"], + [0, 0, 0, "Unexpected any. Specify a different type.", "18"], + [0, 0, 0, "Unexpected any. Specify a different type.", "19"] ], "public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] diff --git a/e2e/various-suite/graph-auto-migrate.spec.ts b/e2e/various-suite/graph-auto-migrate.spec.ts new file mode 100644 index 00000000000..319ba5bec25 --- /dev/null +++ b/e2e/various-suite/graph-auto-migrate.spec.ts @@ -0,0 +1,52 @@ +import { e2e } from '@grafana/e2e'; +const DASHBOARD_ID = 'XMjIZPmik'; +const DASHBOARD_NAME = 'Panel Tests - Graph Time Regions'; + +e2e.scenario({ + describeName: 'Auto-migrate graph panel', + itName: 'Annotation markers exist for time regions', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: DASHBOARD_ID }); + e2e().contains(DASHBOARD_NAME).should('be.visible'); + cy.contains('uplot-main-div').should('not.exist'); + + e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateOldPanels': true } }); + + e2e().wait(1000); + + e2e.components.Panels.Panel.title('Business Hours') + .should('exist') + .within(() => { + e2e.pages.Dashboard.Annotations.marker().should('exist'); + }); + + e2e.components.Panels.Panel.title("Sunday's 20-23") + .should('exist') + .within(() => { + e2e.pages.Dashboard.Annotations.marker().should('exist'); + }); + + e2e.components.Panels.Panel.title('Each day of week') + .should('exist') + .within(() => { + e2e.pages.Dashboard.Annotations.marker().should('exist'); + }); + + e2e.pages.Dashboard.wrapper().children().children('.scrollbar-view').scrollTo('bottom'); + + e2e.components.Panels.Panel.title('05:00') + .should('exist') + .within(() => { + e2e.pages.Dashboard.Annotations.marker().should('exist'); + }); + + e2e.components.Panels.Panel.title('From 22:00 to 00:30 (crossing midnight)') + .should('exist') + .within(() => { + e2e.pages.Dashboard.Annotations.marker().should('exist'); + }); + }, +}); diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 95cd03e5b25..d1c611cc547 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -49,6 +49,7 @@ export const Pages = { }, Dashboard: { url: (uid: string) => `/d/${uid}`, + wrapper: 'data-testid dashboard-page-wrapper', DashNav: { /** * @deprecated use navV2 from Grafana 8.3 instead diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 9bcaaddc3d0..1b89e4692f8 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -237,7 +237,7 @@ function overrideFeatureTogglesFromUrl(config: GrafanaBootConfig) { if (key.startsWith('__feature.')) { const featureToggles = config.featureToggles as Record; const featureName = key.substring(10); - const toggleState = value === 'true'; + const toggleState = value === 'true' || value === ''; // browser rewrites true as '' if (toggleState !== featureToggles[key]) { featureToggles[featureName] = toggleState; console.log(`Setting feature toggle ${featureName} = ${toggleState}`); diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index df666b44ed9..fd4c79749d8 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'; import React, { useLayoutEffect } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -50,7 +51,7 @@ export const Page: PageType = ({ }, [navModel, pageNav, chrome, layout]); return ( -
+
{layout === PageLayoutType.Standard && (
diff --git a/public/app/plugins/panel/graph/tab_display.html b/public/app/plugins/panel/graph/tab_display.html index c8d91f352f8..959dafeabaa 100644 --- a/public/app/plugins/panel/graph/tab_display.html +++ b/public/app/plugins/panel/graph/tab_display.html @@ -9,7 +9,7 @@ Migrate

-

Some features like colored time regions and negative transforms are not supported in the new panel yet.

+

Some features are not supported in the new panel yet.

{ let prevFieldConfig: FieldConfigSource; + let dashboard: DashboardModel; beforeEach(() => { prevFieldConfig = { defaults: {}, overrides: [], }; + + dashboard = createDashboardModelFixture({ + id: 74, + version: 7, + annotations: {}, + links: [], + panels: [], + }); + + getDashboardSrv().setCurrent(dashboard); }); it('simple bars', () => { @@ -82,6 +97,36 @@ describe('Graph Migrations', () => { expect(panel.fieldConfig.overrides[1].matcher.id).toBe(FieldMatcherID.byRegexp); }); + describe('time regions', () => { + test('should migrate', () => { + const old = { + angular: { + timeRegions: [ + { + colorMode: 'red', + fill: true, + fillColor: 'rgba(234, 112, 112, 0.12)', + fromDayOfWeek: 1, + line: true, + lineColor: 'rgba(237, 46, 24, 0.60)', + op: 'time', + }, + ], + }, + }; + + const panel = { datasource: { type: 'datasource', uid: 'gdev-testdata' } } as PanelModel; + dashboard.panels.push(new PanelModelState(panel)); + panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); + expect(dashboard.panels).toHaveLength(1); + expect(dashboard.annotations.list).toHaveLength(2); // built-in + time region + expect( + dashboard.annotations.list.filter((annotation) => annotation.target?.queryType === GrafanaQueryType.TimeRegions) + ).toHaveLength(1); + expect(panel).toMatchSnapshot(); + }); + }); + describe('legend', () => { test('without values', () => { const old = { diff --git a/public/app/plugins/panel/timeseries/migrations.ts b/public/app/plugins/panel/timeseries/migrations.ts index 9f1c774a94c..c1465405a44 100644 --- a/public/app/plugins/panel/timeseries/migrations.ts +++ b/public/app/plugins/panel/timeseries/migrations.ts @@ -31,12 +31,19 @@ import { StackingMode, SortOrder, GraphTransform, + AnnotationQuery, ComparisonOperation, } from '@grafana/schema'; +import { TimeRegionConfig } from 'app/core/utils/timeRegions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { defaultGraphConfig } from './config'; import { PanelOptions } from './panelcfg.gen'; +let dashboardRefreshDebouncer: ReturnType | null = null; + /** * This is called when the panel changes from another panel */ @@ -48,10 +55,25 @@ export const graphPanelChangedHandler: PanelTypeChangedHandler = ( ) => { // Changing from angular/flot panel to react/uPlot if (prevPluginId === 'graph' && prevOptions.angular) { - const { fieldConfig, options } = graphToTimeseriesOptions({ + const { fieldConfig, options, annotations } = graphToTimeseriesOptions({ ...prevOptions.angular, fieldConfig: prevFieldConfig, + panel: panel, }); + + const dashboard = getDashboardSrv().getCurrent(); + if (dashboard && annotations?.length > 0) { + dashboard.annotations.list = [...dashboard.annotations.list, ...annotations]; + + // Trigger a full dashboard refresh when annotations change + if (dashboardRefreshDebouncer == null) { + dashboardRefreshDebouncer = setTimeout(() => { + dashboardRefreshDebouncer = null; + getTimeSrv().refreshTimeModel(); + }); + } + } + panel.fieldConfig = fieldConfig; // Mutates the incoming panel panel.alert = prevOptions.angular.alert; return options; @@ -63,7 +85,13 @@ export const graphPanelChangedHandler: PanelTypeChangedHandler = ( return {}; }; -export function graphToTimeseriesOptions(angular: any): { fieldConfig: FieldConfigSource; options: PanelOptions } { +export function graphToTimeseriesOptions(angular: any): { + fieldConfig: FieldConfigSource; + options: PanelOptions; + annotations: AnnotationQuery[]; +} { + let annotations: AnnotationQuery[] = []; + const overrides: ConfigOverrideRule[] = angular.fieldConfig?.overrides ?? []; const yaxes = angular.yaxes ?? []; let y1 = getFieldConfigFromOldAxis(yaxes[0]); @@ -362,6 +390,55 @@ export function graphToTimeseriesOptions(angular: any): { fieldConfig: FieldConf } } + // timeRegions migration + if (angular.timeRegions?.length) { + let regions: any[] = angular.timeRegions.map((old: GraphTimeRegionConfig, idx: number) => ({ + name: `T${idx + 1}`, + color: old.colorMode !== 'custom' ? old.colorMode : old.fillColor, + line: old.line, + fill: old.fill, + fromDayOfWeek: old.fromDayOfWeek, + toDayOfWeek: old.toDayOfWeek, + from: old.from, + to: old.to, + })); + + regions.forEach((region: GraphTimeRegionConfig, idx: number) => { + const anno: AnnotationQuery = { + datasource: { + type: 'datasource', + uid: 'grafana', + }, + enable: true, + hide: true, // don't show the toggle at the top of the dashboard + filter: { + exclude: false, + ids: [angular.panel.id], + }, + iconColor: region.fillColor ?? (region as any).color, + name: `T${idx + 1}`, + target: { + queryType: GrafanaQueryType.TimeRegions, + refId: 'Anno', + timeRegion: { + fromDayOfWeek: region.fromDayOfWeek, + toDayOfWeek: region.toDayOfWeek, + from: region.from, + to: region.to, + timezone: 'utc', // graph panel was always UTC + }, + }, + }; + + if (region.fill) { + annotations.push(anno); + } else if (region.line) { + anno.iconColor = region.lineColor ?? 'white'; + annotations.push(anno); + } + }); + } + const tooltipConfig = angular.tooltip; if (tooltipConfig) { if (tooltipConfig.shared !== undefined) { @@ -479,9 +556,18 @@ export function graphToTimeseriesOptions(angular: any): { fieldConfig: FieldConf overrides, }, options, + annotations, }; } +interface GraphTimeRegionConfig extends TimeRegionConfig { + colorMode: string; + fill: boolean; + fillColor: string; + line: boolean; + lineColor: string; +} + function getThresholdColor(threshold: AngularThreshold): string { if (threshold.colorMode === 'critical') { return 'red'; From 61e3bbb858af9bf692bff9b0464f0f820d4804d4 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 27 Apr 2023 07:18:38 +0200 Subject: [PATCH 447/729] React Router: start migrating to v6 (#66921) Chore: add the react-router-compat package --- package.json | 2 + public/app/AppWrapper.tsx | 31 +++++++------- public/app/features/plugins/plugin_loader.ts | 19 ++++++++- yarn.lock | 43 ++++++++++++++++++++ 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 2ac4b9dc47d..1f096332438 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,7 @@ "@react-stately/menu": "3.4.1", "@react-stately/tree": "3.3.1", "@reduxjs/toolkit": "1.9.3", + "@remix-run/router": "^1.5.0", "@sentry/browser": "6.19.7", "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", @@ -384,6 +385,7 @@ "react-resizable": "3.0.4", "react-responsive-carousel": "^3.2.23", "react-router-dom": "5.3.3", + "react-router-dom-v5-compat": "^6.10.0", "react-select": "5.7.0", "react-split-pane": "0.1.92", "react-table": "7.8.0", diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index b126832386e..9cf0b4d0aaf 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,7 +1,8 @@ import { Action, KBarProvider } from 'kbar'; import React, { ComponentType } from 'react'; import { Provider } from 'react-redux'; -import { Router, Route, Redirect, Switch } from 'react-router-dom'; +import { Router, Redirect, Switch, RouteComponentProps } from 'react-router-dom'; +import { CompatRouter, CompatRoute } from 'react-router-dom-v5-compat'; import { config, locationService, navigationLogger, reportInteraction } from '@grafana/runtime'; import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, PortalContainer } from '@grafana/ui'; @@ -56,12 +57,12 @@ export class AppWrapper extends React.Component { + render={(props: RouteComponentProps) => { // TODO[Router]: test this logic if (roles?.length) { if (!roles.some((r: string) => contextSrv.hasRole(r))) { @@ -105,17 +106,19 @@ export class AppWrapper extends React.Component
- - {pageBanners.map((Banner, index) => ( - - ))} - - - {ready && this.renderRoutes()} - {bodyRenderHooks.map((Hook, index) => ( - - ))} - + + + {pageBanners.map((Banner, index) => ( + + ))} + + + {ready && this.renderRoutes()} + {bodyRenderHooks.map((Hook, index) => ( + + ))} + +
diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 05a92288f8d..6c1981f245e 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -8,7 +8,8 @@ import prismjs from 'prismjs'; import react from 'react'; import reactDom from 'react-dom'; import * as reactRedux from 'react-redux'; // eslint-disable-line no-restricted-imports -import * as reactRouter from 'react-router-dom'; +import * as reactRouterDom from 'react-router-dom'; +import * as reactRouterCompat from 'react-router-dom-v5-compat'; import * as redux from 'redux'; import * as rxjs from 'rxjs'; import * as rxjsOperators from 'rxjs/operators'; @@ -98,7 +99,21 @@ exposeToPlugin('jquery', jquery); exposeToPlugin('d3', d3); exposeToPlugin('rxjs', rxjs); exposeToPlugin('rxjs/operators', rxjsOperators); -exposeToPlugin('react-router-dom', reactRouter); + +// Migration - React Router v5 -> v6 +// ================================= +// Plugins that still use "react-router-dom@v5" don't depend on react-router directly, so they will not use this import. +// (The react-router-dom@v5 that we expose for them depends on the "react-router" package internally from core.) +// +// Plugins that would like update to "react-router-dom@v6" will need to bundle "react-router-dom", +// however they cannot bundle "react-router" - this would mean that we have two instances of "react-router" +// in the app, which would casue issues. As the "react-router-dom-v5-compat" package re-exports everything from "react-router-dom@v6" +// which then re-exports everything from "react-router@v6", we are in the lucky state to be able to expose a compatible v6 version of the router to plugins by +// just exposing "react-router-dom-v5-compat". +// +// (This means that we are exposing two versions of the same package). +exposeToPlugin('react-router', reactRouterCompat); // react-router-dom@v6, react-router@v6 (included) +exposeToPlugin('react-router-dom', reactRouterDom); // react-router-dom@v5 // Experimental modules exposeToPlugin('prismjs', prismjs); diff --git a/yarn.lock b/yarn.lock index c49e4cbe802..ecfb4884782 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6886,6 +6886,13 @@ __metadata: languageName: node linkType: hard +"@remix-run/router@npm:1.5.0, @remix-run/router@npm:^1.5.0": + version: 1.5.0 + resolution: "@remix-run/router@npm:1.5.0" + checksum: 9c510c174af1553edd1f039ba16e7e3d34e04d53b3bac18814660e31cd0c48297ea4291ff86d0736b560123ebc63ecb62fa525829181d16a8dad15270d6672d7 + languageName: node + linkType: hard + "@rollup/plugin-commonjs@npm:23.0.2": version: 23.0.2 resolution: "@rollup/plugin-commonjs@npm:23.0.2" @@ -20195,6 +20202,7 @@ __metadata: "@react-types/overlays": 3.6.4 "@react-types/shared": 3.16.0 "@reduxjs/toolkit": 1.9.3 + "@remix-run/router": ^1.5.0 "@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1 "@sentry/browser": 6.19.7 "@sentry/types": 6.19.7 @@ -20406,6 +20414,7 @@ __metadata: react-resizable: 3.0.4 react-responsive-carousel: ^3.2.23 react-router-dom: 5.3.3 + react-router-dom-v5-compat: ^6.10.0 react-select: 5.7.0 react-select-event: 5.5.1 react-simple-compat: 1.2.3 @@ -20867,6 +20876,15 @@ __metadata: languageName: node linkType: hard +"history@npm:^5.3.0": + version: 5.3.0 + resolution: "history@npm:5.3.0" + dependencies: + "@babel/runtime": ^7.7.6 + checksum: d73c35df49d19ac172f9547d30a21a26793e83f16a78386d99583b5bf1429cc980799fcf1827eb215d31816a6600684fba9686ce78104e23bd89ec239e7c726f + languageName: node + linkType: hard + "hoist-non-react-statics@npm:3.3.2, hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -31201,6 +31219,20 @@ __metadata: languageName: node linkType: hard +"react-router-dom-v5-compat@npm:^6.10.0": + version: 6.10.0 + resolution: "react-router-dom-v5-compat@npm:6.10.0" + dependencies: + history: ^5.3.0 + react-router: 6.10.0 + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + react-router-dom: 4 || 5 + checksum: 550c91cd5e70b8b115e965c7816694d6df19e9ac7d6c12ab4d3c805f9110d1143c8a335ba89f492d5e7e8b726309f5f907b9501b4df0f17ada3bda40a42a0800 + languageName: node + linkType: hard + "react-router-dom@npm:5.3.3": version: 5.3.3 resolution: "react-router-dom@npm:5.3.3" @@ -31238,6 +31270,17 @@ __metadata: languageName: node linkType: hard +"react-router@npm:6.10.0": + version: 6.10.0 + resolution: "react-router@npm:6.10.0" + dependencies: + "@remix-run/router": 1.5.0 + peerDependencies: + react: ">=16.8" + checksum: c9fce46147c04257d7d6fa1f5bbfac96c5fdd0b15f26918bd12b2e5fe9143977c5a4452272f9b85795a22e29ec105a60d0bbe036118efc52b383d163cd8829ab + languageName: node + linkType: hard + "react-select-event@npm:5.5.1": version: 5.5.1 resolution: "react-select-event@npm:5.5.1" From d949aa778b2696a3ebcdd5ee06d6e75b7a10a7e1 Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu, 27 Apr 2023 08:19:58 +0100 Subject: [PATCH 448/729] Traces: Only show filtered spans (#66986) * Only show filtered spans * Add & update tests --- .../features/explore/TraceView/TraceView.tsx | 4 ++ .../TraceView/TraceViewContainer.test.tsx | 60 ++++++++++++++----- .../NewTracePageHeader.test.tsx | 2 + .../TracePageHeader/NewTracePageHeader.tsx | 6 ++ .../NewTracePageSearchBar.test.tsx | 8 +++ .../TracePageHeader/NewTracePageSearchBar.tsx | 33 +++++++++- .../SpanFilters/SpanFilters.test.tsx | 2 + .../SpanFilters/SpanFilters.tsx | 6 ++ .../TraceTimelineViewer/ListView/index.tsx | 2 +- .../TraceTimelineViewer/SpanBarRow.tsx | 11 ++-- .../VirtualizedTraceView.tsx | 23 +++++-- .../components/TraceTimelineViewer/index.tsx | 1 + 12 files changed, 131 insertions(+), 27 deletions(-) diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 1d2c655f79a..2c2049d0974 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -99,6 +99,7 @@ export function TraceView(props: Props) { ); const [newTraceViewHeaderFocusedSpanIdForSearch, setNewTraceViewHeaderFocusedSpanIdForSearch] = useState(''); const [showSpanFilters, setShowSpanFilters] = useToggle(false); + const [showSpanFilterMatchesOnly, setShowSpanFilterMatchesOnly] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); const styles = useStyles2(getStyles); @@ -163,6 +164,8 @@ export function TraceView(props: Props) { setSearch={setNewTraceViewHeaderSearch} showSpanFilters={showSpanFilters} setShowSpanFilters={setShowSpanFilters} + showSpanFilterMatchesOnly={showSpanFilterMatchesOnly} + setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly} focusedSpanIdForSearch={newTraceViewHeaderFocusedSpanIdForSearch} setFocusedSpanIdForSearch={setNewTraceViewHeaderFocusedSpanIdForSearch} spanFilterMatches={spanFilterMatches} @@ -226,6 +229,7 @@ export function TraceView(props: Props) { ? newTraceViewHeaderFocusedSpanIdForSearch : props.focusedSpanIdForSearch! } + showSpanFilterMatchesOnly={showSpanFilterMatchesOnly} createFocusSpanLink={createFocusSpanLink} topOfViewRef={topOfViewRef} topOfViewRefType={topOfViewRefType} diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index d061560861d..86677a298e6 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -1,9 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { createRef } from 'react'; import { Provider } from 'react-redux'; import { getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { ExploreId } from 'app/types'; import { configureStore } from '../../../store/configureStore'; @@ -47,37 +48,48 @@ function renderTraceViewContainer(frames = [frameOld]) { } describe('TraceViewContainer', () => { + let user: ReturnType; + beforeEach(() => { + jest.useFakeTimers(); + // Need to use delay: null here to work with fakeTimers + // see https://github.com/testing-library/user-event/issues/833 + user = userEvent.setup({ delay: null }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('toggles children visibility', async () => { renderTraceViewContainer(); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); - await userEvent.click(screen.getAllByText('', { selector: 'span[data-testid="SpanTreeOffset--indentGuide"]' })[0]); + await user.click(screen.getAllByText('', { selector: 'span[data-testid="SpanTreeOffset--indentGuide"]' })[0]); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1); - await userEvent.click(screen.getAllByText('', { selector: 'span[data-testid="SpanTreeOffset--indentGuide"]' })[0]); + await user.click(screen.getAllByText('', { selector: 'span[data-testid="SpanTreeOffset--indentGuide"]' })[0]); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); }); it('toggles collapses and expands one level of spans', async () => { renderTraceViewContainer(); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); - await userEvent.click(screen.getByLabelText('Collapse +1')); + await user.click(screen.getByLabelText('Collapse +1')); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(2); - await userEvent.click(screen.getByLabelText('Expand +1')); + await user.click(screen.getByLabelText('Expand +1')); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); }); it('toggles collapses and expands all levels', async () => { renderTraceViewContainer(); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); - await userEvent.click(screen.getByLabelText('Collapse All')); + await user.click(screen.getByLabelText('Collapse All')); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1); - await userEvent.click(screen.getByLabelText('Expand All')); + await user.click(screen.getByLabelText('Expand All')); expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); }); it('searches for spans', async () => { renderTraceViewContainer(); - await userEvent.type(screen.getByPlaceholderText('Find...'), '1ed38015486087ca'); + await user.type(screen.getByPlaceholderText('Find...'), '1ed38015486087ca'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className ).toContain('rowMatchingFilter'); @@ -85,40 +97,58 @@ describe('TraceViewContainer', () => { it('can select next/prev results', async () => { renderTraceViewContainer(); - await userEvent.type(screen.getByPlaceholderText('Find...'), 'logproto'); + await user.type(screen.getByPlaceholderText('Find...'), 'logproto'); const nextResultButton = screen.getByRole('button', { name: 'Next results button' }); const prevResultButton = screen.getByRole('button', { name: 'Prev results button' }); const suffix = screen.getByLabelText('Search bar suffix'); - await userEvent.click(nextResultButton); + await user.click(nextResultButton); expect(suffix.textContent).toBe('1 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className ).toContain('rowFocused'); - await userEvent.click(nextResultButton); + await user.click(nextResultButton); expect(suffix.textContent).toBe('2 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className ).toContain('rowFocused'); - await userEvent.click(nextResultButton); + await user.click(nextResultButton); expect(suffix.textContent).toBe('1 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className ).toContain('rowFocused'); - await userEvent.click(prevResultButton); + await user.click(prevResultButton); expect(suffix.textContent).toBe('2 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className ).toContain('rowFocused'); - await userEvent.click(prevResultButton); + await user.click(prevResultButton); expect(suffix.textContent).toBe('1 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className ).toContain('rowFocused'); - await userEvent.click(prevResultButton); + await user.click(prevResultButton); expect(suffix.textContent).toBe('2 of 2'); expect( screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className ).toContain('rowFocused'); }); + + it('show matches only works as expected', async () => { + config.featureToggles.newTraceViewHeader = true; + renderTraceViewContainer(); + const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' }); + await user.click(spanFiltersButton); + + await user.click(screen.getByLabelText('Select tag key')); + const tagOption = screen.getByText('http.status_code'); + await waitFor(() => expect(tagOption).toBeInTheDocument()); + await user.click(tagOption); + + expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3); + const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' }); + expect(matchesSwitch).toBeInTheDocument(); + await user.click(matchesSwitch); + expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1); + }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx index 64db99647b1..eec1a45a7b2 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx @@ -30,6 +30,8 @@ const setup = () => { setSearch: jest.fn(), showSpanFilters: true, setShowSpanFilters: jest.fn(), + showSpanFilterMatchesOnly: false, + setShowSpanFilterMatchesOnly: jest.fn(), spanFilterMatches: undefined, focusedSpanIdForSearch: '', setFocusedSpanIdForSearch: jest.fn(), diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx index 835ff170d6b..1e0ffdcbb9f 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx @@ -39,6 +39,8 @@ export type TracePageHeaderProps = { setSearch: React.Dispatch>; showSpanFilters: boolean; setShowSpanFilters: (isOpen: boolean) => void; + showSpanFilterMatchesOnly: boolean; + setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; focusedSpanIdForSearch: string; setFocusedSpanIdForSearch: React.Dispatch>; spanFilterMatches: Set | undefined; @@ -54,6 +56,8 @@ export const NewTracePageHeader = memo((props: TracePageHeaderProps) => { setSearch, showSpanFilters, setShowSpanFilters, + showSpanFilterMatchesOnly, + setShowSpanFilterMatchesOnly, focusedSpanIdForSearch, setFocusedSpanIdForSearch, spanFilterMatches, @@ -131,6 +135,8 @@ export const NewTracePageHeader = memo((props: TracePageHeaderProps) => { trace={trace} showSpanFilters={showSpanFilters} setShowSpanFilters={setShowSpanFilters} + showSpanFilterMatchesOnly={showSpanFilterMatchesOnly} + setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly} search={search} setSearch={setSearch} spanFilterMatches={spanFilterMatches} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx index fdc550211d9..4515b0bbbeb 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx @@ -22,6 +22,8 @@ import NewTracePageSearchBar, { TracePageSearchBarProps } from './NewTracePageSe const defaultProps = { search: defaultFilters, setFocusedSpanIdForSearch: jest.fn(), + showSpanFilterMatchesOnly: false, + setShowSpanFilterMatchesOnly: jest.fn(), }; describe('', () => { @@ -51,4 +53,10 @@ describe('', () => { expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false); expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false); }); + + it('renders show span filter matches only switch', async () => { + render(); + const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' }); + expect(matchesSwitch).toBeInTheDocument(); + }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx index c0ffd8460d3..bf0ddd18a8b 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx @@ -16,7 +16,7 @@ import { css } from '@emotion/css'; import React, { memo, Dispatch, SetStateAction, useEffect, useMemo } from 'react'; import { config, reportInteraction } from '@grafana/runtime'; -import { Button, useStyles2 } from '@grafana/ui'; +import { Button, Switch, useStyles2 } from '@grafana/ui'; import { SearchProps } from '../../useSearch'; import { convertTimeFilter } from '../utils/filter-spans'; @@ -25,6 +25,8 @@ export type TracePageSearchBarProps = { search: SearchProps; setSearch: React.Dispatch>; spanFilterMatches: Set | undefined; + showSpanFilterMatchesOnly: boolean; + setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; focusedSpanIdForSearch: string; setFocusedSpanIdForSearch: Dispatch>; datasourceType: string; @@ -32,7 +34,16 @@ export type TracePageSearchBarProps = { }; export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) { - const { search, spanFilterMatches, focusedSpanIdForSearch, setFocusedSpanIdForSearch, datasourceType, reset } = props; + const { + search, + spanFilterMatches, + focusedSpanIdForSearch, + setFocusedSpanIdForSearch, + datasourceType, + reset, + showSpanFilterMatchesOnly, + setShowSpanFilterMatchesOnly, + } = props; const styles = useStyles2(getStyles); useEffect(() => { @@ -108,6 +119,14 @@ export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProp > Reset +
+ setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)} + label="Show matches only switch" + /> + setShowSpanFilterMatchesOnly(!showSpanFilterMatchesOnly)}>Show matches only +
)} diff --git a/public/app/plugins/datasource/phlare/dataquery.cue b/public/app/plugins/datasource/phlare/dataquery.cue index f77dd20a9b6..1a766f8d182 100644 --- a/public/app/plugins/datasource/phlare/dataquery.cue +++ b/public/app/plugins/datasource/phlare/dataquery.cue @@ -39,6 +39,8 @@ composableKinds: DataQuery: { profileTypeId: string // Allows to group the results. groupBy: [...string] + // Sets the maximum number of nodes in the flamegraph. + maxNodes?: int64 #PhlareQueryType: "metrics" | "profile" | *"both" @cuetsy(kind="type") }, ] diff --git a/public/app/plugins/datasource/phlare/dataquery.gen.ts b/public/app/plugins/datasource/phlare/dataquery.gen.ts index 0d4936d7867..7be23baa6a2 100644 --- a/public/app/plugins/datasource/phlare/dataquery.gen.ts +++ b/public/app/plugins/datasource/phlare/dataquery.gen.ts @@ -25,6 +25,10 @@ export interface GrafanaPyroscope extends common.DataQuery { * Specifies the query label selectors. */ labelSelector: string; + /** + * Sets the maximum number of nodes in the flamegraph. + */ + maxNodes?: number; /** * Specifies the type of profile to query. */ diff --git a/public/app/plugins/datasource/phlare/datasource.ts b/public/app/plugins/datasource/phlare/datasource.ts index 4cf80c31e71..21e4f31dfe9 100644 --- a/public/app/plugins/datasource/phlare/datasource.ts +++ b/public/app/plugins/datasource/phlare/datasource.ts @@ -82,6 +82,7 @@ export class PhlareDataSource extends DataSourceWithBackend Date: Thu, 27 Apr 2023 11:19:45 +0200 Subject: [PATCH 454/729] CloudWatch: Deprecate dynamic labels feature toggle, remove support for Alias in backend (#66494) --- pkg/tsdb/cloudwatch/cloudwatch.go | 2 - .../kinds/dataquery/types_dataquery_gen.go | 3 +- .../cloudwatch/metric_data_input_builder.go | 3 +- .../metric_data_input_builder_test.go | 9 +- .../cloudwatch/metric_data_query_builder.go | 3 +- .../metric_data_query_builder_test.go | 40 ++--- .../cloudwatch/models/cloudwatch_query.go | 65 ++++---- .../models/cloudwatch_query_test.go | 150 +++++++---------- pkg/tsdb/cloudwatch/response_parser.go | 84 +--------- pkg/tsdb/cloudwatch/response_parser_test.go | 155 ++++-------------- pkg/tsdb/cloudwatch/time_series_query.go | 1 - pkg/tsdb/cloudwatch/time_series_query_test.go | 52 ++---- .../datasource/cloudwatch/dataquery.cue | 3 +- .../datasource/cloudwatch/dataquery.gen.ts | 3 +- 14 files changed, 162 insertions(+), 411 deletions(-) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 771698f3563..f1e62c28372 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "regexp" "sync" "github.com/aws/aws-sdk-go/aws" @@ -51,7 +50,6 @@ const ( ) var logger = log.New("tsdb.cloudwatch") -var aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider, features featuremgmt.FeatureToggles) *CloudWatchService { logger.Debug("Initializing") diff --git a/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go index f0b62e3612d..d442e211870 100644 --- a/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go @@ -422,7 +422,8 @@ type CloudWatchMetricsQuery struct { // The ID of the AWS account to query for the metric, specifying `all` will query all accounts that the monitoring account is permitted to query. AccountId *string `json:"accountId,omitempty"` - // To be deprecated. Use label + // Deprecated: use label + // @deprecated use label Alias *string `json:"alias,omitempty"` // For mixed data sources the selected datasource is on the query level. diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder.go b/pkg/tsdb/cloudwatch/metric_data_input_builder.go index 962e628b5f4..ebb667c2610 100644 --- a/pkg/tsdb/cloudwatch/metric_data_input_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder.go @@ -7,7 +7,6 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -19,7 +18,7 @@ func (e *cloudWatchExecutor) buildMetricDataInput(logger log.Logger, startTime t ScanBy: aws.String("TimestampAscending"), } - shouldSetLabelOptions := e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels) && len(queries) > 0 && len(queries[0].TimezoneUTCOffset) > 0 + shouldSetLabelOptions := len(queries) > 0 && len(queries[0].TimezoneUTCOffset) > 0 if shouldSetLabelOptions { metricDataInput.LabelOptions = &cloudwatch.LabelOptions{ diff --git a/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go index 24b441eeba2..320cd8109fd 100644 --- a/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_input_builder_test.go @@ -20,17 +20,14 @@ func TestMetricDataInputBuilder(t *testing.T) { name string timezoneUTCOffset string expectedLabelOptions *cloudwatch.LabelOptions - featureEnabled bool }{ - {name: "when timezoneUTCOffset is provided and feature is enabled", timezoneUTCOffset: "+1234", expectedLabelOptions: &cloudwatch.LabelOptions{Timezone: aws.String("+1234")}, featureEnabled: true}, - {name: "when timezoneUTCOffset is not provided and feature is enabled", timezoneUTCOffset: "", expectedLabelOptions: nil, featureEnabled: true}, - {name: "when timezoneUTCOffset is provided and feature is disabled", timezoneUTCOffset: "+1234", expectedLabelOptions: nil, featureEnabled: false}, - {name: "when timezoneUTCOffset is not provided and feature is disabled", timezoneUTCOffset: "", expectedLabelOptions: nil, featureEnabled: false}, + {name: "when timezoneUTCOffset is provided", timezoneUTCOffset: "+1234", expectedLabelOptions: &cloudwatch.LabelOptions{Timezone: aws.String("+1234")}}, + {name: "when timezoneUTCOffset is not provided", timezoneUTCOffset: "", expectedLabelOptions: nil}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels, tc.featureEnabled)) + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := getBaseQuery() query.TimezoneUTCOffset = tc.timezoneUTCOffset diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder.go b/pkg/tsdb/cloudwatch/metric_data_query_builder.go index 5552d7f3032..0a6fa90316b 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder.go @@ -10,7 +10,6 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -20,7 +19,7 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(logger log.Logger, query *mode ReturnData: aws.Bool(query.ReturnData), } - if e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels) && len(query.Label) > 0 { + if len(query.Label) > 0 { mdq.Label = &query.Label } diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go index 84b827ec925..d075fdfd7fd 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go @@ -95,8 +95,8 @@ func TestMetricDataQueryBuilder(t *testing.T) { assert.Equal(t, `SUM([a,b])`, *mdq.Expression) }) - t.Run("should set label when dynamic labels feature toggle is enabled", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) + t.Run("should set label", func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := getBaseQuery() query.Label = "some label" @@ -107,35 +107,19 @@ func TestMetricDataQueryBuilder(t *testing.T) { assert.Equal(t, "some label", *mdq.Label) }) - testCases := map[string]struct { - feature *featuremgmt.FeatureManager - label string - }{ - "should not set label when dynamic labels feature toggle is disabled": { - feature: featuremgmt.WithFeatures(), - label: "some label", - }, - "should not set label for empty string query label": { - feature: featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels), - label: "", - }, - } + t.Run("should not set label for empty string query label", func(t *testing.T) { + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) + query := getBaseQuery() + query.Label = "" - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, tc.feature) - query := getBaseQuery() - query.Label = tc.label + mdq, err := executor.buildMetricDataQuery(logger, query) - mdq, err := executor.buildMetricDataQuery(logger, query) - - assert.NoError(t, err) - assert.Nil(t, mdq.Label) - }) - } + assert.NoError(t, err) + assert.Nil(t, mdq.Label) + }) t.Run(`should not specify accountId when it is "all"`, func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := &models.CloudWatchQuery{ Namespace: "AWS/EC2", MetricName: "CPUUtilization", @@ -153,7 +137,7 @@ func TestMetricDataQueryBuilder(t *testing.T) { }) t.Run("should set accountId when it is specified", func(t *testing.T) { - executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) + executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := &models.CloudWatchQuery{ Namespace: "AWS/EC2", MetricName: "CPUUtilization", diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go index e88723e5cb4..7666e709c24 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go @@ -63,7 +63,6 @@ type CloudWatchQuery struct { ReturnData bool Dimensions map[string][]string Period int - Alias string Label string MatchExact bool UsedExpression string @@ -150,7 +149,7 @@ func (q *CloudWatchQuery) IsMultiValuedDimensionExpression() bool { return false } -func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time, dynamicLabelEnabled bool) (string, error) { +func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time) (string, error) { if q.IsMathExpression() || q.MetricQueryType == MetricQueryTypeQuery { return "", nil } @@ -166,9 +165,7 @@ func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time, if q.isSearchExpression() { metricExpressions := &metricExpression{Expression: q.UsedExpression} - if dynamicLabelEnabled { - metricExpressions.Label = q.Label - } + metricExpressions.Label = q.Label link.Metrics = []interface{}{metricExpressions} } else { metricStat := []interface{}{q.Namespace, q.MetricName} @@ -179,9 +176,7 @@ func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time, Stat: q.Statistic, Period: q.Period, } - if dynamicLabelEnabled { - metricStatMeta.Label = q.Label - } + metricStatMeta.Label = q.Label if q.AccountId != nil { metricStatMeta.AccountId = *q.AccountId } @@ -221,7 +216,7 @@ type metricsDataQuery struct { // ParseMetricDataQueries decodes the metric data queries json, validates, sets default values and returns an array of CloudWatchQueries. // The CloudWatchQuery has a 1 to 1 mapping to a query editor row -func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, defaultRegion string, logger log.Logger, dynamicLabelsEnabled, +func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, defaultRegion string, logger log.Logger, crossAccountQueryingEnabled bool) ([]*CloudWatchQuery, error) { var metricDataQueries = make(map[string]metricsDataQuery) for _, query := range dataQueries { @@ -251,10 +246,6 @@ func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time TimezoneUTCOffset: mdq.TimezoneUTCOffset, } - if mdq.Alias != nil { - cwQuery.Alias = *mdq.Alias - } - if mdq.MetricName != nil { cwQuery.MetricName = *mdq.MetricName } @@ -271,13 +262,17 @@ func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time cwQuery.Expression = *mdq.Expression } + if mdq.Label != nil { + cwQuery.Label = *mdq.Label + } + if err := cwQuery.validateAndSetDefaults(refId, mdq, startTime, endTime, defaultRegion, crossAccountQueryingEnabled); err != nil { return nil, &QueryError{Err: err, RefID: refId} } cwQuery.applyMacros(startTime, endTime) - cwQuery.migrateLegacyQuery(mdq, dynamicLabelsEnabled) + cwQuery.migrateLegacyQuery(mdq) result = append(result, cwQuery) } @@ -291,9 +286,9 @@ func (q *CloudWatchQuery) applyMacros(startTime, endTime time.Time) { } } -func (q *CloudWatchQuery) migrateLegacyQuery(query metricsDataQuery, dynamicLabelsEnabled bool) { +func (q *CloudWatchQuery) migrateLegacyQuery(query metricsDataQuery) { q.Statistic = getStatistic(query) - q.Label = getLabel(query, dynamicLabelsEnabled) + q.Label = getLabel(query) } func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery metricsDataQuery, startTime, endTime time.Time, @@ -386,33 +381,33 @@ var aliasPatterns = map[string]string{ var legacyAliasRegexp = regexp.MustCompile(`{{\s*(.+?)\s*}}`) -func getLabel(query metricsDataQuery, dynamicLabelsEnabled bool) string { +func getLabel(query metricsDataQuery) string { + deprecatedAlias := query.Alias //nolint:staticcheck + if query.Label != nil { return *query.Label } - if query.Alias != nil && *query.Alias == "" { + if deprecatedAlias != nil && *deprecatedAlias == "" { return "" } var result string - if dynamicLabelsEnabled { - fullAliasField := "" - if query.Alias != nil { - fullAliasField = *query.Alias - } - matches := legacyAliasRegexp.FindAllStringSubmatch(fullAliasField, -1) - - for _, groups := range matches { - fullMatch := groups[0] - subgroup := groups[1] - if dynamicLabel, ok := aliasPatterns[subgroup]; ok { - fullAliasField = strings.ReplaceAll(fullAliasField, fullMatch, dynamicLabel) - } else { - fullAliasField = strings.ReplaceAll(fullAliasField, fullMatch, fmt.Sprintf(`${PROP('Dim.%s')}`, subgroup)) - } - } - result = fullAliasField + fullAliasField := "" + if deprecatedAlias != nil { + fullAliasField = *deprecatedAlias } + matches := legacyAliasRegexp.FindAllStringSubmatch(fullAliasField, -1) + + for _, groups := range matches { + fullMatch := groups[0] + subgroup := groups[1] + if dynamicLabel, ok := aliasPatterns[subgroup]; ok { + fullAliasField = strings.ReplaceAll(fullAliasField, fullMatch, dynamicLabel) + } else { + fullAliasField = strings.ReplaceAll(fullAliasField, fullMatch, fmt.Sprintf(`${PROP('Dim.%s')}`, subgroup)) + } + } + result = fullAliasField return result } diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go index 8901397f203..007a0af7015 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go @@ -8,12 +8,12 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" "github.com/grafana/kindsys" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" ) @@ -39,12 +39,12 @@ func TestCloudWatchQuery(t *testing.T) { MetricEditorMode: MetricEditorModeBuilder, } - deepLink, err := query.BuildDeepLink(startTime, endTime, false) + deepLink, err := query.BuildDeepLink(startTime, endTime) require.NoError(t, err) assert.Empty(t, deepLink) }) - t.Run("does not include label in case dynamic label is diabled", func(t *testing.T) { + t.Run("includes label and it's a metric stat query", func(t *testing.T) { startTime := time.Now() endTime := startTime.Add(2 * time.Hour) query := &CloudWatchQuery{ @@ -63,36 +63,12 @@ func TestCloudWatchQuery(t *testing.T) { MetricEditorMode: MetricEditorModeBuilder, } - deepLink, err := query.BuildDeepLink(startTime, endTime, false) + deepLink, err := query.BuildDeepLink(startTime, endTime) require.NoError(t, err) - assert.NotContains(t, deepLink, "label") + assert.Contains(t, deepLink, "label") }) - t.Run("includes label in case dynamic label is enabled and it's a metric stat query", func(t *testing.T) { - startTime := time.Now() - endTime := startTime.Add(2 * time.Hour) - query := &CloudWatchQuery{ - RefId: "A", - Region: "us-east-1", - Expression: "", - Statistic: "Average", - Period: 300, - Id: "id1", - MatchExact: true, - Label: "${PROP('Namespace')}", - Dimensions: map[string][]string{ - "InstanceId": {"i-12345678"}, - }, - MetricQueryType: MetricQueryTypeSearch, - MetricEditorMode: MetricEditorModeBuilder, - } - - deepLink, err := query.BuildDeepLink(startTime, endTime, false) - require.NoError(t, err) - assert.NotContains(t, deepLink, "label") - }) - - t.Run("includes label in case dynamic label is enabled and it's a math expression query", func(t *testing.T) { + t.Run("includes label and it's a math expression query", func(t *testing.T) { startTime := time.Now() endTime := startTime.Add(2 * time.Hour) query := &CloudWatchQuery{ @@ -108,9 +84,9 @@ func TestCloudWatchQuery(t *testing.T) { MetricEditorMode: MetricEditorModeRaw, } - deepLink, err := query.BuildDeepLink(startTime, endTime, false) + deepLink, err := query.BuildDeepLink(startTime, endTime) require.NoError(t, err) - assert.NotContains(t, deepLink, "label") + assert.Contains(t, deepLink, "label") }) t.Run("includes account id in case its a metric stat query and an account id is set", func(t *testing.T) { @@ -133,7 +109,7 @@ func TestCloudWatchQuery(t *testing.T) { MetricEditorMode: MetricEditorModeBuilder, } - deepLink, err := query.BuildDeepLink(startTime, endTime, false) + deepLink, err := query.BuildDeepLink(startTime, endTime) require.NoError(t, err) assert.Contains(t, deepLink, "accountId%22%3A%22123456789") }) @@ -155,7 +131,7 @@ func TestCloudWatchQuery(t *testing.T) { MetricEditorMode: MetricEditorModeRaw, } - deepLink, err := query.BuildDeepLink(startTime, endTime, false) + deepLink, err := query.BuildDeepLink(startTime, endTime) require.NoError(t, err) assert.NotContains(t, deepLink, "accountId%22%3A%22123456789") }) @@ -311,7 +287,7 @@ func TestRequestParser(t *testing.T) { }, } - migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), "us-east-2", logger, false, false) + migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, migratedQueries, 1) require.NotNil(t, migratedQueries[0]) @@ -342,7 +318,7 @@ func TestRequestParser(t *testing.T) { }, } - results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, results, 1) res := results[0] @@ -385,7 +361,7 @@ func TestRequestParser(t *testing.T) { }, } - results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, results, 1) res := results[0] @@ -418,7 +394,7 @@ func TestRequestParser(t *testing.T) { }, } - _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.Error(t, err) assert.Equal(t, `error parsing query "", failed to parse dimensions: unknown type as dimension value`, err.Error()) @@ -447,7 +423,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -479,7 +455,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.Local().Add(time.Minute * time.Duration(5)) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 60, res[0].Period) @@ -489,7 +465,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -1) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 60, res[0].Period) @@ -498,7 +474,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days", func(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 300, res[0].Period) @@ -508,7 +484,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -7) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 900, res[0].Period) @@ -518,7 +494,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -30) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 3600, res[0].Period) @@ -528,7 +504,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(0, 0, -90) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -538,7 +514,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(-1, 0, 0) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.Nil(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -548,7 +524,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { to := time.Now() from := to.AddDate(-2, 0, 0) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 86400, res[0].Period) @@ -557,7 +533,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 16 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -14) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 300, res[0].Period) @@ -566,7 +542,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 90 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -88) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 3600, res[0].Period) @@ -575,7 +551,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { t.Run("Time range is 2 days, but 456 days ago", func(t *testing.T) { to := time.Now().AddDate(0, 0, -454) from := to.AddDate(0, 0, -2) - res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, from, to, "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) assert.Equal(t, 21600, res[0].Period) @@ -590,7 +566,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }`), }, } - _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + _, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.Error(t, err) assert.Equal(t, `error parsing query "", failed to parse period as duration: time: invalid duration "invalid"`, err.Error()) }) @@ -605,7 +581,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 1) @@ -692,7 +668,7 @@ func Test_ParseMetricDataQueries_query_type_and_metric_editor_mode_and_GMD_query ), }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -718,7 +694,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -739,7 +715,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -760,7 +736,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -779,7 +755,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -800,7 +776,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -821,7 +797,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -844,7 +820,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -865,7 +841,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) { }`), }, } - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), "us-east-2", logger, false) require.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -882,7 +858,6 @@ func Test_ParseMetricDataQueries_sets_label_when_label_is_present_in_json_query( "region":"us-east-1", "namespace":"ec2", "metricName":"CPUUtilization", - "alias":"some alias", "label":"some label", "dimensions":{"InstanceId":["test"]}, "statistic":"Average", @@ -892,11 +867,10 @@ func Test_ParseMetricDataQueries_sets_label_when_label_is_present_in_json_query( }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, true, false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) - assert.Equal(t, "some alias", res[0].Alias) // untouched assert.Equal(t, "some label", res[0].Label) } @@ -936,12 +910,12 @@ func Test_migrateAliasToDynamicLabel_single_query_preserves_old_alias_and_create }, } - assert.Equal(t, tc.expectedLabel, getLabel(queryToMigrate, true)) + assert.Equal(t, tc.expectedLabel, getLabel(queryToMigrate)) }) } } func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { - t.Run("migrates alias to label when label does not already exist and feature toggle enabled", func(t *testing.T) { + t.Run("migrates alias to label when label does not already exist", func(t *testing.T) { query := []backend.DataQuery{ { JSON: []byte(`{ @@ -958,13 +932,12 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, true, false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) - assert.Equal(t, "{{period}} {{any_other_word}}", res[0].Alias) assert.Equal(t, "${PROP('Period')} ${PROP('Dim.any_other_word')}", res[0].Label) assert.Equal(t, map[string][]string{"InstanceId": {"test"}}, res[0].Dimensions) assert.Equal(t, true, res[0].ReturnData) @@ -1005,7 +978,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, true, false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 2) @@ -1014,7 +987,6 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }) require.NotNil(t, res[0]) - assert.Equal(t, "{{period}} {{any_other_word}}", res[0].Alias) assert.Equal(t, "${PROP('Period')} ${PROP('Dim.any_other_word')}", res[0].Label) assert.Equal(t, map[string][]string{"InstanceId": {"test"}}, res[0].Dimensions) assert.Equal(t, true, res[0].ReturnData) @@ -1025,7 +997,6 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { assert.Equal(t, "Average", res[0].Statistic) require.NotNil(t, res[1]) - assert.Equal(t, "{{ label }}", res[1].Alias) assert.Equal(t, "${LABEL}", res[1].Label) assert.Equal(t, map[string][]string{"InstanceId": {"test"}}, res[1].Dimensions) assert.Equal(t, true, res[1].ReturnData) @@ -1042,19 +1013,11 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { dynamicLabelsFeatureToggleEnabled bool expectedLabel string }{ - "when label already exists, feature toggle enabled": { + "when label already exists": { labelJson: `"label":"some label",`, dynamicLabelsFeatureToggleEnabled: true, - expectedLabel: "some label"}, - "when label does not exist, feature toggle is disabled": { - labelJson: "", - dynamicLabelsFeatureToggleEnabled: false, - expectedLabel: "", + expectedLabel: "some label", }, - "when label already exists, feature toggle is disabled": { - labelJson: `"label":"some label",`, - dynamicLabelsFeatureToggleEnabled: false, - expectedLabel: "some label"}, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { @@ -1074,13 +1037,12 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) { }`, tc.labelJson)), }, } - res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, tc.dynamicLabelsFeatureToggleEnabled, false) + res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) - assert.Equal(t, "{{period}} {{any_other_word}}", res[0].Alias) assert.Equal(t, tc.expectedLabel, res[0].Label) assert.Equal(t, map[string][]string{"InstanceId": {"test"}}, res[0].Dimensions) assert.Equal(t, true, res[0].ReturnData) @@ -1101,7 +1063,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte("{}"), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.Error(t, err) assert.Equal(t, `error parsing query "", query must have either statistic or statistics field`, err.Error()) @@ -1114,7 +1076,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"type":"some other type", "statistic":"Average", "matchExact":false}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) assert.Empty(t, actual) @@ -1126,7 +1088,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average"}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) assert.NotEmpty(t, actual) @@ -1138,7 +1100,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average"}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) assert.Len(t, actual, 1) @@ -1152,7 +1114,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE { JSON: []byte(`{"statistic":"Average","matchExact":false}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) assert.Len(t, actual, 1) @@ -1168,7 +1130,7 @@ func Test_ParseMetricDataQueries_account_Id(t *testing.T) { { JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, true) + }, time.Now(), time.Now(), "us-east-2", logger, true) assert.NoError(t, err) require.Len(t, actual, 1) @@ -1183,7 +1145,7 @@ func Test_ParseMetricDataQueries_account_Id(t *testing.T) { { JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`), }, - }, time.Now(), time.Now(), "us-east-2", logger, false, false) + }, time.Now(), time.Now(), "us-east-2", logger, false) assert.NoError(t, err) require.Len(t, actual, 1) @@ -1215,7 +1177,7 @@ func Test_ParseMetricDataQueries_default_region(t *testing.T) { } region := "us-east-2" - res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), region, logger, false, false) + res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), region, logger, false) assert.NoError(t, err) require.Len(t, res, 1) require.NotNil(t, res[0]) @@ -1253,7 +1215,6 @@ func Test_ParseMetricDataQueries_ApplyMacros(t *testing.T) { "region":"us-east-1", "namespace":"ec2", "metricName":"CPUUtilization", - "alias":"{{period}} {{any_other_word}}", "dimensions":{"InstanceId":["test"]}, "statistic":"Average", "period":"600", @@ -1263,7 +1224,7 @@ func Test_ParseMetricDataQueries_ApplyMacros(t *testing.T) { "metricEditorMode": 1 }`), }, - }, tc.startTime, time.Now(), "us-east-1", logger, false, false) + }, tc.startTime, time.Now(), "us-east-1", logger, false) assert.NoError(t, err) assert.Equal(t, fmt.Sprintf("SEARCH('{AWS/EC2,InstanceId}', 'Average', %s)", tc.expectedPeriod), actual[0].Expression) }) @@ -1279,7 +1240,6 @@ func Test_ParseMetricDataQueries_ApplyMacros(t *testing.T) { "region":"us-east-1", "namespace":"ec2", "metricName":"CPUUtilization", - "alias":"{{period}} {{any_other_word}}", "dimensions":{"InstanceId":["test"]}, "statistic":"Average", "period":"600", @@ -1289,7 +1249,7 @@ func Test_ParseMetricDataQueries_ApplyMacros(t *testing.T) { "metricEditorMode": 1 }`), }, - }, time.Now(), time.Now(), "us-east-1", logger, false, false) + }, time.Now(), time.Now(), "us-east-1", logger, false) assert.NoError(t, err) assert.Equal(t, "SEARCH('{AWS/EC2,InstanceId}', 'Average', $__period_auto)", actual[0].Expression) }) diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index 34c66d5c1a2..78b7154afa7 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -3,14 +3,12 @@ package cloudwatch import ( "fmt" "sort" - "strconv" "strings" "time" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" ) @@ -32,7 +30,7 @@ func (e *cloudWatchExecutor) parseResponse(startTime time.Time, endTime time.Tim } var err error - dataRes.Frames, err = buildDataFrames(startTime, endTime, response, queryRow, e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels)) + dataRes.Frames, err = buildDataFrames(startTime, endTime, response, queryRow) if err != nil { return nil, err } @@ -110,12 +108,12 @@ func getLabels(cloudwatchLabel string, query *models.CloudWatchQuery) data.Label } func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse models.QueryRowResponse, - query *models.CloudWatchQuery, dynamicLabelEnabled bool) (data.Frames, error) { + query *models.CloudWatchQuery) (data.Frames, error) { frames := data.Frames{} for _, metric := range aggregatedResponse.Metrics { label := *metric.Label - deepLink, err := query.BuildDeepLink(startTime, endTime, dynamicLabelEnabled) + deepLink, err := query.BuildDeepLink(startTime, endTime) if err != nil { return nil, err } @@ -143,14 +141,10 @@ func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []*time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, labels, []*float64{}) - frameName := label - if !dynamicLabelEnabled { - frameName = formatAlias(query, query.Statistic, labels, label) - } - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName, Links: createDataLinks(deepLink)}) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: label, Links: createDataLinks(deepLink)}) emptyFrame := data.Frame{ - Name: frameName, + Name: label, Fields: []*data.Field{ timeField, valueField, @@ -175,14 +169,10 @@ func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, timestamps) valueField := data.NewField(data.TimeSeriesValueFieldName, labels, points) - frameName := label - if !dynamicLabelEnabled { - frameName = formatAlias(query, query.Statistic, labels, label) - } - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName, Links: createDataLinks(deepLink)}) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: label, Links: createDataLinks(deepLink)}) frame := data.Frame{ - Name: frameName, + Name: label, Fields: []*data.Field{ timeField, valueField, @@ -213,66 +203,6 @@ func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse return frames, nil } -func formatAlias(query *models.CloudWatchQuery, stat string, dimensions map[string]string, label string) string { - region := query.Region - namespace := query.Namespace - metricName := query.MetricName - period := strconv.Itoa(query.Period) - - if query.IsUserDefinedSearchExpression() { - pIndex := strings.LastIndex(query.Expression, ",") - period = strings.Trim(query.Expression[pIndex+1:], " )") - sIndex := strings.LastIndex(query.Expression[:pIndex], ",") - stat = strings.Trim(query.Expression[sIndex+1:pIndex], " '") - } - - if len(query.Alias) == 0 && query.IsMathExpression() { - return query.Id - } - if len(query.Alias) == 0 && query.IsInferredSearchExpression() && !query.IsMultiValuedDimensionExpression() { - return label - } - if len(query.Alias) == 0 && query.MetricQueryType == models.MetricQueryTypeQuery { - return label - } - - // common fields - commonFields := map[string]string{ - "region": region, - "period": period, - } - if len(label) != 0 { - commonFields["label"] = label - } - - // since the SQL query string is not (yet) parsed, we don't know what namespace, metric, statistic and labels it's using at this point - if query.MetricQueryType != models.MetricQueryTypeQuery { - commonFields["namespace"] = namespace - commonFields["metric"] = metricName - commonFields["stat"] = stat - for k, v := range dimensions { - commonFields[k] = v - } - } - - result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte { - labelName := strings.Replace(string(in), "{{", "", 1) - labelName = strings.Replace(labelName, "}}", "", 1) - labelName = strings.TrimSpace(labelName) - if val, exists := commonFields[labelName]; exists { - return []byte(val) - } - - return in - }) - - if string(result) == "" { - return metricName + "_" + stat - } - - return string(result) -} - func createDataLinks(link string) []data.DataLink { dataLinks := []data.DataLink{} if link != "" { diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index 5613268e5fd..1701bcce634 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "testing" "time" @@ -27,8 +26,6 @@ func loadGetMetricDataOutputsFromFile(filePath string) ([]*cloudwatch.GetMetricD } func TestCloudWatchResponseParser(t *testing.T) { - startTime := time.Now() - endTime := startTime.Add(2 * time.Hour) t.Run("when aggregating multi-outputs response", func(t *testing.T) { getMetricDataOutputs, err := loadGetMetricDataOutputsFromFile("./testdata/multiple-outputs-query-a.json") require.NoError(t, err) @@ -137,8 +134,12 @@ func TestCloudWatchResponseParser(t *testing.T) { }) }) }) +} - t.Run("Expand dimension value using exact match", func(t *testing.T) { +func Test_buildDataFrames_uses_response_label_as_frame_name(t *testing.T) { + startTime := time.Now() + endTime := startTime.Add(2 * time.Hour) + t.Run("using exact match", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ Metrics: []*cloudwatch.MetricDataResult{ @@ -186,92 +187,28 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{LoadBalancer}} Expanded", MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeBuilder, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) frame1 := frames[0] - assert.Equal(t, "lb1 Expanded", frame1.Name) + assert.Equal(t, "lb1", frame1.Name) assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"]) frame2 := frames[1] - assert.Equal(t, "lb2 Expanded", frame2.Name) + assert.Equal(t, "lb2", frame2.Name) assert.Equal(t, "lb2", frame2.Fields[1].Labels["LoadBalancer"]) }) - t.Run("Expand dimension value using substring", func(t *testing.T) { - timestamp := time.Unix(0, 0) - response := &models.QueryRowResponse{ - Metrics: []*cloudwatch.MetricDataResult{ - { - Id: aws.String("id1"), - Label: aws.String("lb1 Sum"), - Timestamps: []*time.Time{ - aws.Time(timestamp), - aws.Time(timestamp.Add(time.Minute)), - aws.Time(timestamp.Add(3 * time.Minute)), - }, - Values: []*float64{ - aws.Float64(10), - aws.Float64(20), - aws.Float64(30), - }, - StatusCode: aws.String("Complete"), - }, - { - Id: aws.String("id2"), - Label: aws.String("lb2 Average"), - Timestamps: []*time.Time{ - aws.Time(timestamp), - aws.Time(timestamp.Add(time.Minute)), - aws.Time(timestamp.Add(3 * time.Minute)), - }, - Values: []*float64{ - aws.Float64(10), - aws.Float64(20), - aws.Float64(30), - }, - StatusCode: aws.String("Complete"), - }, - }} - - query := &models.CloudWatchQuery{ - RefId: "refId1", - Region: "us-east-1", - Namespace: "AWS/ApplicationELB", - MetricName: "TargetResponseTime", - Dimensions: map[string][]string{ - "LoadBalancer": {"lb1", "lb2"}, - "TargetGroup": {"tg"}, - }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded", - MetricQueryType: models.MetricQueryTypeSearch, - MetricEditorMode: models.MetricEditorModeBuilder, - } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) - require.NoError(t, err) - - frame1 := frames[0] - assert.Equal(t, "lb1 Expanded", frame1.Name) - assert.Equal(t, "lb1", frame1.Fields[1].Labels["LoadBalancer"]) - - frame2 := frames[1] - assert.Equal(t, "lb2 Expanded", frame2.Name) - assert.Equal(t, "lb2", frame2.Fields[1].Labels["LoadBalancer"]) - }) - - t.Run("Expand dimension value using wildcard", func(t *testing.T) { + t.Run("using wildcard", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ Metrics: []*cloudwatch.MetricDataResult{ { Id: aws.String("lb3"), - Label: aws.String("lb3"), + Label: aws.String("some label lb3"), Timestamps: []*time.Time{ aws.Time(timestamp), aws.Time(timestamp.Add(time.Minute)), @@ -286,7 +223,7 @@ func TestCloudWatchResponseParser(t *testing.T) { }, { Id: aws.String("lb4"), - Label: aws.String("lb4"), + Label: aws.String("some label lb4"), Timestamps: []*time.Time{ aws.Time(timestamp), aws.Time(timestamp.Add(time.Minute)), @@ -313,24 +250,23 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{LoadBalancer}} Expanded", MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeBuilder, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) - assert.Equal(t, "lb3 Expanded", frames[0].Name) - assert.Equal(t, "lb4 Expanded", frames[1].Name) + assert.Equal(t, "some label lb3", frames[0].Name) + assert.Equal(t, "some label lb4", frames[1].Name) }) - t.Run("Expand dimension value when no values are returned and a multi-valued template variable is used", func(t *testing.T) { + t.Run("when no values are returned and a multi-valued template variable is used", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ Metrics: []*cloudwatch.MetricDataResult{ { Id: aws.String("lb3"), - Label: aws.String("lb3"), + Label: aws.String("some label"), Timestamps: []*time.Time{ aws.Time(timestamp), aws.Time(timestamp.Add(time.Minute)), @@ -351,25 +287,24 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{LoadBalancer}} Expanded", MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeBuilder, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) assert.Len(t, frames, 2) - assert.Equal(t, "lb1 Expanded", frames[0].Name) - assert.Equal(t, "lb2 Expanded", frames[1].Name) + assert.Equal(t, "some label", frames[0].Name) + assert.Equal(t, "some label", frames[1].Name) }) - t.Run("Expand dimension value when no values are returned and a multi-valued template variable and two single-valued dimensions are used", func(t *testing.T) { + t.Run("when no values are returned and a multi-valued template variable and two single-valued dimensions are used", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ Metrics: []*cloudwatch.MetricDataResult{ { Id: aws.String("lb3"), - Label: aws.String("lb3"), + Label: aws.String("some label"), Timestamps: []*time.Time{ aws.Time(timestamp), aws.Time(timestamp.Add(time.Minute)), @@ -393,25 +328,24 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{LoadBalancer}} Expanded {{InstanceType}} - {{Resource}}", MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeBuilder, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) assert.Len(t, frames, 2) - assert.Equal(t, "lb1 Expanded micro - res", frames[0].Name) - assert.Equal(t, "lb2 Expanded micro - res", frames[1].Name) + assert.Equal(t, "some label", frames[0].Name) + assert.Equal(t, "some label", frames[1].Name) }) - t.Run("Should only expand certain fields when using SQL queries", func(t *testing.T) { + t.Run("when using SQL queries", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &models.QueryRowResponse{ Metrics: []*cloudwatch.MetricDataResult{ { Id: aws.String("lb3"), - Label: aws.String("lb3"), + Label: aws.String("some label"), Timestamps: []*time.Time{ aws.Time(timestamp), }, @@ -433,20 +367,13 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{LoadBalancer}} {{InstanceType}} {{metric}} {{namespace}} {{stat}} {{region}} {{period}}", MetricQueryType: models.MetricQueryTypeQuery, MetricEditorMode: models.MetricEditorModeRaw, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) - assert.False(t, strings.Contains(frames[0].Name, "AWS/ApplicationELB")) - assert.False(t, strings.Contains(frames[0].Name, "lb1")) - assert.False(t, strings.Contains(frames[0].Name, "micro")) - assert.False(t, strings.Contains(frames[0].Name, "AWS/ApplicationELB")) - - assert.True(t, strings.Contains(frames[0].Name, "us-east-1")) - assert.True(t, strings.Contains(frames[0].Name, "60")) + assert.Equal(t, "some label", frames[0].Name) }) t.Run("Parse cloudwatch response", func(t *testing.T) { @@ -455,7 +382,7 @@ func TestCloudWatchResponseParser(t *testing.T) { Metrics: []*cloudwatch.MetricDataResult{ { Id: aws.String("id1"), - Label: aws.String("lb"), + Label: aws.String("some label"), Timestamps: []*time.Time{ aws.Time(timestamp), aws.Time(timestamp.Add(time.Minute)), @@ -482,15 +409,14 @@ func TestCloudWatchResponseParser(t *testing.T) { }, Statistic: "Average", Period: 60, - Alias: "{{namespace}}_{{metric}}_{{stat}}", MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeBuilder, } - frames, err := buildDataFrames(startTime, endTime, *response, query, false) + frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) frame := frames[0] - assert.Equal(t, "AWS/ApplicationELB_TargetResponseTime_Average", frame.Name) + assert.Equal(t, "some label", frame.Name) assert.Equal(t, "Time", frame.Fields[0].Name) assert.Equal(t, "lb", frame.Fields[1].Labels["LoadBalancer"]) assert.Equal(t, 10.0, *frame.Fields[1].At(0).(*float64)) @@ -499,23 +425,4 @@ func TestCloudWatchResponseParser(t *testing.T) { assert.Equal(t, "Value", frame.Fields[1].Name) assert.Equal(t, "", frame.Fields[1].Config.DisplayName) }) - - t.Run("buildDataFrames should use response label as frame name when dynamic label is enabled", func(t *testing.T) { - response := &models.QueryRowResponse{ - Metrics: []*cloudwatch.MetricDataResult{ - { - Label: aws.String("some response label"), - Timestamps: []*time.Time{}, - Values: []*float64{aws.Float64(10)}, - StatusCode: aws.String("Complete"), - }, - }, - } - - frames, err := buildDataFrames(startTime, endTime, *response, &models.CloudWatchQuery{}, true) - - assert.NoError(t, err) - require.Len(t, frames, 1) - assert.Equal(t, "some response label", frames[0].Name) - }) } diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index 32a839f0767..e53e7a056f9 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -37,7 +37,6 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger } requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, instance.Settings.Region, logger, - e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels), e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying)) if err != nil { return nil, err diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 5ee82a2ae99..d51a3ca5e13 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -77,7 +77,6 @@ func TestTimeSeriesQuery(t *testing.T) { }, "region": "us-east-2", "id": "a", - "alias": "NetworkOut", "statistics": [ "Maximum" ], @@ -104,7 +103,6 @@ func TestTimeSeriesQuery(t *testing.T) { }, "region": "us-east-2", "id": "b", - "alias": "NetworkIn", "statistics": [ "Maximum" ], @@ -275,7 +273,6 @@ type queryParameters struct { MetricEditorMode dataquery.CloudWatchMetricsQueryMetricEditorMode `json:"metricEditorMode"` Dimensions queryDimensions `json:"dimensions"` Expression string `json:"expression"` - Alias string `json:"alias"` Label *string `json:"label"` Statistic string `json:"statistic"` Period string `json:"period"` @@ -300,7 +297,6 @@ func newTestQuery(t testing.TB, p queryParameters) json.RawMessage { Expression string `json:"expression"` Region string `json:"region"` ID string `json:"id"` - Alias string `json:"alias"` Label *string `json:"label"` Statistic string `json:"statistic"` Period string `json:"period"` @@ -317,7 +313,6 @@ func newTestQuery(t testing.TB, p queryParameters) json.RawMessage { MetricEditorMode: p.MetricEditorMode, Dimensions: p.Dimensions, Expression: p.Expression, - Alias: p.Alias, Label: p.Label, Statistic: p.Statistic, Period: p.Period, @@ -346,10 +341,10 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { return DataSource{Settings: models.CloudWatchSettings{}}, nil }) - t.Run("passes query label as GetMetricData label when dynamic labels feature toggle is enabled", func(t *testing.T) { + t.Run("passes query label as GetMetricData label", func(t *testing.T) { 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)) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) query := newTestQuery(t, queryParameters{ Label: aws.String("${PROP('Period')} some words ${PROP('Dim.InstanceId')}"), }) @@ -378,27 +373,17 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { }) testCases := map[string]struct { - feature *featuremgmt.FeatureManager parameters queryParameters }{ - "should not pass GetMetricData label when query label is empty, dynamic labels is enabled": { - feature: featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels), - }, - "should not pass GetMetricData label when query label is empty string, dynamic labels is enabled": { - feature: featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels), - parameters: queryParameters{Label: aws.String("")}, - }, - "should not pass GetMetricData label when dynamic labels is disabled": { - feature: featuremgmt.WithFeatures(), - parameters: queryParameters{Label: aws.String("${PROP('Period')} some words ${PROP('Dim.InstanceId')}")}, - }, + "should not pass GetMetricData label when query label is empty": {}, + "should not pass GetMetricData label when query label is empty string": {parameters: queryParameters{Label: aws.String("")}}, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { api = mocks.MetricsAPI{} api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) - executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, tc.feature) + executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -425,7 +410,7 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { } } -func Test_QueryData_response_data_frame_names(t *testing.T) { +func Test_QueryData_response_data_frame_name_is_always_response_label(t *testing.T) { origNewCWClient := NewCWClient t.Cleanup(func() { NewCWClient = origNewCWClient @@ -449,11 +434,10 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) - t.Run("where user defines search expression and alias is defined, then frame name prioritizes period and stat from expression over input", func(t *testing.T) { + t.Run("where user defines search expression", func(t *testing.T) { query := newTestQuery(t, queryParameters{ - MetricQueryType: models.MetricQueryTypeSearch, // contributes to isUserDefinedSearchExpression = true - MetricEditorMode: models.MetricEditorModeRaw, // contributes to isUserDefinedSearchExpression = true - Alias: "{{period}} {{stat}}", + MetricQueryType: models.MetricQueryTypeSearch, // contributes to isUserDefinedSearchExpression = true + MetricEditorMode: models.MetricEditorModeRaw, // contributes to isUserDefinedSearchExpression = true Expression: `SEARCH('{AWS/EC2,InstanceId} MetricName="CPUUtilization"', 'Average', 300)`, // period 300 and stat 'Average' parsed from this expression Statistic: "Maximum", // stat parsed from expression takes precedence over 'Maximum' Period: "1200", // period parsed from expression takes precedence over 1200 @@ -471,10 +455,10 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) assert.NoError(t, err) - assert.Equal(t, "300 Average", resp.Responses["A"].Frames[0].Name) + assert.Equal(t, labelFromGetMetricData, resp.Responses["A"].Frames[0].Name) }) - t.Run("where no alias is provided and query is math expression, then frame name is queryId", func(t *testing.T) { + t.Run("where query is math expression", func(t *testing.T) { query := newTestQuery(t, queryParameters{ MetricQueryType: models.MetricQueryTypeSearch, MetricEditorMode: models.MetricEditorModeRaw, @@ -492,10 +476,10 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) assert.NoError(t, err) - assert.Equal(t, queryId, resp.Responses["A"].Frames[0].Name) + assert.Equal(t, labelFromGetMetricData, resp.Responses["A"].Frames[0].Name) }) - t.Run("where no alias provided and query type is MetricQueryTypeQuery, then frame name is label", func(t *testing.T) { + t.Run("where query type is MetricQueryTypeQuery", func(t *testing.T) { query := newTestQuery(t, queryParameters{ MetricQueryType: models.MetricQueryTypeQuery, }) @@ -515,7 +499,7 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { assert.Equal(t, labelFromGetMetricData, resp.Responses["A"].Frames[0].Name) }) - // where query is inferred search expression and not multivalued dimension expression, then frame name is label + // where query is inferred search expression and not multivalued dimension expression testCasesReturningLabel := map[string]queryParameters{ "with specific dimensions, matchExact false": {Dimensions: queryDimensions{[]string{"some-instance"}}, MatchExact: false}, "with wildcard dimensions, matchExact false": {Dimensions: queryDimensions{[]string{"*"}}, MatchExact: false}, @@ -542,7 +526,7 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) } - // complementary test cases to above return default of "metricName_stat" + // complementary test cases to above testCasesReturningMetricStat := map[string]queryParameters{ "with specific dimensions, matchExact true": { Dimensions: queryDimensions{[]string{"some-instance"}}, @@ -585,7 +569,7 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }) assert.NoError(t, err) - assert.Equal(t, "CPUUtilization_Maximum", resp.Responses["A"].Frames[0].Name) + assert.Equal(t, labelFromGetMetricData, resp.Responses["A"].Frames[0].Name) }) } } @@ -627,7 +611,6 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { }, "region": "us-east-2", "id": "a", - "alias": "NetworkOut", "statistic": "Maximum", "period": "300", "hide": false, @@ -668,7 +651,6 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { }, "region": "us-east-2", "id": "a", - "alias": "NetworkOut", "statistic": "Maximum", "period": "300", "hide": false, @@ -710,7 +692,6 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { }, "region": "us-east-2", "id": "a", - "alias": "NetworkOut", "statistic": "Maximum", "period": "300", "hide": false, @@ -752,7 +733,6 @@ func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) { }, "region": "us-east-2", "id": "a", - "alias": "NetworkOut", "statistic": "Maximum", "period": "300", "hide": false, diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.cue b/public/app/plugins/datasource/cloudwatch/dataquery.cue index e4248e20b6b..5ca33d0bbff 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.cue +++ b/public/app/plugins/datasource/cloudwatch/dataquery.cue @@ -67,7 +67,8 @@ composableKinds: DataQuery: { metricEditorMode?: #MetricEditorMode // ID can be used to reference other queries in math expressions. The ID can include numbers, letters, and underscore, and must start with a lowercase letter. id: string - // To be deprecated. Use label + // Deprecated: use label + // @deprecated use label alias?: string // Change the time series legend names using dynamic labels. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/graph-dynamic-labels.html for more details. label?: string diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts index 32d3bc67012..c1a8751c66c 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts +++ b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts @@ -65,7 +65,8 @@ export type Dimensions = Record)>; */ export interface CloudWatchMetricsQuery extends common.DataQuery, MetricStat { /** - * To be deprecated. Use label + * Deprecated: use label + * @deprecated use label */ alias?: string; /** From 334ecd1be79a2cd51af4bc24a7822bf187893470 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 27 Apr 2023 10:29:26 +0100 Subject: [PATCH 455/729] NestedFolders: Select all for Browse and Search (#67227) * NestedFolders: Select all for BrowseView and SearchView * show fake indeterminate state for SearchView * fix types * Select search results as additional pages are loaded * fix (de)select all between browse and search * tests * fix test * fix test --- .../api/browseDashboardsAPI.ts | 2 +- .../BrowseActions/DeleteModal.test.tsx | 1 + .../BrowseActions/MoveModal.test.tsx | 1 + .../components/BrowseView.tsx | 2 + .../components/DashboardsTree.test.tsx | 5 ++ .../components/DashboardsTree.tsx | 19 +++-- .../components/SearchView.tsx | 20 +++-- .../features/browse-dashboards/state/hooks.ts | 4 +- .../browse-dashboards/state/reducers.test.ts | 80 ++++++++++++++++++- .../browse-dashboards/state/reducers.ts | 39 +++++++++ .../features/browse-dashboards/state/slice.ts | 3 +- .../app/features/browse-dashboards/types.ts | 4 +- .../components/SearchResultsTable.test.tsx | 14 ++-- .../page/components/SearchResultsTable.tsx | 24 +++++- 14 files changed, 196 insertions(+), 22 deletions(-) diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index a3e0ca101a7..3802bdf9326 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -93,5 +93,5 @@ export const browseDashboardsAPI = createApi({ }), }); -export const { useGetFolderQuery, useGetAffectedItemsQuery } = browseDashboardsAPI; +export const { useGetFolderQuery, useLazyGetFolderQuery, useGetAffectedItemsQuery } = browseDashboardsAPI; export { skipToken } from '@reduxjs/toolkit/query/react'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx index aec7aef75b3..d5fb11eec3d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.test.tsx @@ -18,6 +18,7 @@ describe('browse-dashboards DeleteModal', () => { onConfirm: mockOnConfirm, onDismiss: mockOnDismiss, selectedItems: { + $all: false, folder: {}, dashboard: {}, panel: {}, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx index a5ae8362f95..5ac7b077728 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx @@ -28,6 +28,7 @@ describe('browse-dashboards MoveModal', () => { onConfirm: mockOnConfirm, onDismiss: mockOnDismiss, selectedItems: { + $all: false, folder: {}, dashboard: {}, panel: {}, diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 8245f789213..e67c2fbc4e3 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -9,6 +9,7 @@ import { fetchChildren, setFolderOpenState, setItemSelectionState, + setAllSelection, } from '../state'; import { DashboardsTree } from './DashboardsTree'; @@ -53,6 +54,7 @@ export function BrowseView({ folderUID, width, height }: BrowseViewProps) { height={height} selectedItems={selectedItems} onFolderClick={handleFolderClick} + onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))} onItemSelectionChange={handleItemSelectionChange} /> ); diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index 42847fc3c2a..65b498d29e0 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -21,6 +21,7 @@ describe('browse-dashboards DashboardsTree', () => { const dashboard = wellFormedDashboard(2); const noop = () => {}; const selectedItems = { + $all: false, folder: {}, dashboard: {}, panel: {}, @@ -35,6 +36,7 @@ describe('browse-dashboards DashboardsTree', () => { height={HEIGHT} onFolderClick={noop} onItemSelectionChange={noop} + onAllSelectionChange={noop} /> ); expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument(); @@ -51,6 +53,7 @@ describe('browse-dashboards DashboardsTree', () => { height={HEIGHT} onFolderClick={noop} onItemSelectionChange={noop} + onAllSelectionChange={noop} /> ); expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); @@ -67,6 +70,7 @@ describe('browse-dashboards DashboardsTree', () => { height={HEIGHT} onFolderClick={handler} onItemSelectionChange={noop} + onAllSelectionChange={noop} /> ); const folderButton = screen.getByLabelText('Collapse folder'); @@ -84,6 +88,7 @@ describe('browse-dashboards DashboardsTree', () => { height={HEIGHT} onFolderClick={noop} onItemSelectionChange={noop} + onAllSelectionChange={noop} /> ); expect(screen.queryByText('Empty folder')).toBeInTheDocument(); diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index 11cf3529c80..10f2dee9f3a 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import React, { useMemo } from 'react'; -import { CellProps, Column, TableInstance, useTable } from 'react-table'; +import { CellProps, Column, HeaderProps, TableInstance, useTable } from 'react-table'; import { FixedSizeList as List } from 'react-window'; import { GrafanaTheme2 } from '@grafana/data'; @@ -21,15 +21,19 @@ interface DashboardsTreeProps { height: number; selectedItems: DashboardTreeSelection; onFolderClick: (uid: string, newOpenState: boolean) => void; + onAllSelectionChange: (newState: boolean) => void; onItemSelectionChange: (item: DashboardViewItem, newState: boolean) => void; } type DashboardsTreeColumn = Column; -type DashboardsTreeCellProps = CellProps & { +type DashboardTreeHeaderProps = HeaderProps & { // Note: userProps for cell renderers (e.g. second argument in `cell.render('Cell', foo)` ) // aren't typed, so we must be careful when accessing this selectedItems?: DashboardsTreeProps['selectedItems']; }; +type DashboardsTreeCellProps = CellProps & { + selectedItems?: DashboardsTreeProps['selectedItems']; +}; const HEADER_HEIGHT = 35; const ROW_HEIGHT = 35; @@ -40,6 +44,7 @@ export function DashboardsTree({ height, selectedItems, onFolderClick, + onAllSelectionChange, onItemSelectionChange, }: DashboardsTreeProps) { const styles = useStyles2(getStyles); @@ -48,7 +53,10 @@ export function DashboardsTree({ const checkboxColumn: DashboardsTreeColumn = { id: 'checkbox', width: 0, - Header: () => , + Header: ({ selectedItems }: DashboardTreeHeaderProps) => { + const isAllSelected = selectedItems?.$all ?? false; + return onAllSelectionChange(ev.currentTarget.checked)} />; + }, Cell: ({ row: { original: row }, selectedItems }: DashboardsTreeCellProps) => { const item = row.item; if (item.kind === 'ui-empty-folder' || !selectedItems) { @@ -56,6 +64,7 @@ export function DashboardsTree({ } const isSelected = selectedItems?.[item.kind][item.uid] ?? false; + return ( - {column.render('Header')} + {column.render('Header', { selectedItems })}
); })} diff --git a/public/app/features/browse-dashboards/components/SearchView.tsx b/public/app/features/browse-dashboards/components/SearchView.tsx index 0af421731f4..92e985f4d0f 100644 --- a/public/app/features/browse-dashboards/components/SearchView.tsx +++ b/public/app/features/browse-dashboards/components/SearchView.tsx @@ -7,7 +7,7 @@ import { useSearchStateManager } from 'app/features/search/state/SearchStateMana import { DashboardViewItemKind } from 'app/features/search/types'; import { useDispatch, useSelector } from 'app/types'; -import { setItemSelectionState } from '../state'; +import { setAllSelection, setItemSelectionState, useHasSelection } from '../state'; interface SearchViewProps { height: number; @@ -17,6 +17,7 @@ interface SearchViewProps { export function SearchView({ width, height }: SearchViewProps) { const dispatch = useDispatch(); const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems); + const hasSelection = useHasSelection(); const { keyboardEvents } = useKeyNavigationListener(); const [searchState, stateManager] = useSearchStateManager(); @@ -25,18 +26,27 @@ export function SearchView({ width, height }: SearchViewProps) { const selectionChecker = useCallback( (kind: string | undefined, uid: string): boolean => { - if (!kind || kind === '*') { + if (!kind) { + return false; + } + + // Currently, this indicates _some_ items are selected, not nessicarily all are + // selected. + if (kind === '*' && uid === '*') { + return hasSelection; + } else if (kind === '*') { + // Unsure how this case can happen return false; } return selectedItems[assertDashboardViewItemKind(kind)][uid] ?? false; }, - [selectedItems] + [selectedItems, hasSelection] ); const clearSelection = useCallback(() => { - console.log('TODO: clearSelection'); - }, []); + dispatch(setAllSelection({ isSelected: false })); + }, [dispatch]); const handleItemSelectionChange = useCallback( (kind: string, uid: string) => { diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index dd6a78319bc..3bd672b0946 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -106,10 +106,10 @@ function createFlatTree( function getSelectedItemsForActions( selectedItemsState: DashboardTreeSelection, childrenByParentUID: Record -): Omit { +): Omit { // Take a copy of the selected items to work with // We don't care about panels here, only dashboards and folders can be moved or deleted - const result: Omit = { + const result = { dashboard: { ...selectedItemsState.dashboard }, folder: { ...selectedItemsState.folder }, }; diff --git a/public/app/features/browse-dashboards/state/reducers.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts index 9c9cbde83c3..32e5b3bbb99 100644 --- a/public/app/features/browse-dashboards/state/reducers.test.ts +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -1,7 +1,12 @@ import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { BrowseDashboardsState } from '../types'; -import { extraReducerFetchChildrenFulfilled, setFolderOpenState, setItemSelectionState } from './reducers'; +import { + extraReducerFetchChildrenFulfilled, + setAllSelection, + setFolderOpenState, + setItemSelectionState, +} from './reducers'; function createInitialState(): BrowseDashboardsState { return { @@ -9,6 +14,7 @@ function createInitialState(): BrowseDashboardsState { childrenByParentUID: {}, openFolders: {}, selectedItems: { + $all: false, dashboard: {}, folder: {}, panel: {}, @@ -84,6 +90,7 @@ describe('browse-dashboards reducers', () => { extraReducerFetchChildrenFulfilled(state, action); expect(state.selectedItems).toEqual({ + $all: false, dashboard: { [childDashboard.uid]: true, }, @@ -114,6 +121,7 @@ describe('browse-dashboards reducers', () => { setItemSelectionState(state, { type: 'setItemSelectionState', payload: { item: dashboard, isSelected: true } }); expect(state.selectedItems).toEqual({ + $all: false, dashboard: { [dashboard.uid]: true, }, @@ -139,6 +147,7 @@ describe('browse-dashboards reducers', () => { }); expect(state.selectedItems).toEqual({ + $all: false, dashboard: { [childDashboard.uid]: true, [grandchildDashboard.uid]: true, @@ -175,6 +184,7 @@ describe('browse-dashboards reducers', () => { }); expect(state.selectedItems).toEqual({ + $all: false, dashboard: { [childDashboard.uid]: true, [grandchildDashboard.uid]: false, @@ -187,4 +197,72 @@ describe('browse-dashboards reducers', () => { }); }); }); + + describe('setAllSelection', () => { + it('selects all loaded items', () => { + const state = createInitialState(); + + let seed = 1; + const topLevelDashboard = wellFormedDashboard(seed++).item; + const topLevelFolder = wellFormedFolder(seed++).item; + const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item; + const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; + const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; + + state.rootItems = [topLevelFolder, topLevelDashboard]; + state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; + state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + + state.selectedItems.folder[childFolder.uid] = false; + state.selectedItems.dashboard[grandchildDashboard.uid] = true; + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true } }); + + expect(state.selectedItems).toEqual({ + $all: true, + dashboard: { + [topLevelDashboard.uid]: true, + [childDashboard.uid]: true, + [grandchildDashboard.uid]: true, + }, + folder: { + [topLevelFolder.uid]: true, + [childFolder.uid]: true, + }, + panel: {}, + }); + }); + + it('deselects all items', () => { + const state = createInitialState(); + + let seed = 1; + const topLevelDashboard = wellFormedDashboard(seed++).item; + const topLevelFolder = wellFormedFolder(seed++).item; + const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item; + const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; + const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; + + state.rootItems = [topLevelFolder, topLevelDashboard]; + state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; + state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + + state.selectedItems.folder[childFolder.uid] = false; + state.selectedItems.dashboard[grandchildDashboard.uid] = true; + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: false } }); + + // Deselecting only sets selection = false for things already selected + expect(state.selectedItems).toEqual({ + $all: false, + dashboard: { + [grandchildDashboard.uid]: false, + }, + folder: { + [childFolder.uid]: false, + }, + panel: {}, + }); + }); + }); }); diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 5d6f6674163..ac79052360c 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -82,6 +82,45 @@ export function setItemSelectionState( } } +export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) { + const { isSelected } = action.payload; + + state.selectedItems.$all = isSelected; + + // Search works a bit differently so the state here does different things... + // In search: + // - When "Selecting all", it sends individual state updates with setItemSelectionState. + // - When "Deselecting all", it uses this setAllSelection. Search results aren't stored in + // redux, so we just need to iterate over the selected items to flip them to false + + if (isSelected) { + for (const folderUID in state.childrenByParentUID) { + const children = state.childrenByParentUID[folderUID] ?? []; + + for (const child of children) { + state.selectedItems[child.kind][child.uid] = isSelected; + } + } + + for (const child of state.rootItems) { + state.selectedItems[child.kind][child.uid] = isSelected; + } + } else { + // if deselecting only need to loop over what we've already selected + for (const kind in state.selectedItems) { + if (!(kind === 'dashboard' || kind === 'panel' || kind === 'folder')) { + continue; + } + + const selection = state.selectedItems[kind]; + + for (const uid in selection) { + selection[uid] = isSelected; + } + } + } +} + function findItem( rootItems: DashboardViewItem[], childrenByUID: Record, diff --git a/public/app/features/browse-dashboards/state/slice.ts b/public/app/features/browse-dashboards/state/slice.ts index 564f1fc4db7..39db1edbdb9 100644 --- a/public/app/features/browse-dashboards/state/slice.ts +++ b/public/app/features/browse-dashboards/state/slice.ts @@ -15,6 +15,7 @@ const initialState: BrowseDashboardsState = { dashboard: {}, folder: {}, panel: {}, + $all: false, }, }; @@ -30,7 +31,7 @@ const browseDashboardsSlice = createSlice({ export const browseDashboardsReducer = browseDashboardsSlice.reducer; -export const { setFolderOpenState, setItemSelectionState } = browseDashboardsSlice.actions; +export const { setFolderOpenState, setItemSelectionState, setAllSelection } = browseDashboardsSlice.actions; export default { browseDashboards: browseDashboardsReducer, diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index e3b08f17d6c..7ace5982127 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -1,6 +1,8 @@ import { DashboardViewItem as DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; -export type DashboardTreeSelection = Record>; +export type DashboardTreeSelection = Record> & { + $all: boolean; +}; export interface BrowseDashboardsState { rootItems: DashboardViewItem[]; diff --git a/public/app/features/search/page/components/SearchResultsTable.test.tsx b/public/app/features/search/page/components/SearchResultsTable.test.tsx index fafa6abdf96..979865b9f6a 100644 --- a/public/app/features/search/page/components/SearchResultsTable.test.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.test.tsx @@ -53,7 +53,7 @@ describe('SearchResultsTable', () => { jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); }); - it('shows the table with the correct accessible label', () => { + it('shows the table with the correct accessible label', async () => { render( { width={1000} /> ); - expect(screen.getByRole('table', { name: 'Search results table' })).toBeInTheDocument(); + const table = await screen.findByRole('table', { name: 'Search results table' }); + expect(table).toBeInTheDocument(); }); it('has the correct row headers', async () => { @@ -82,12 +83,13 @@ describe('SearchResultsTable', () => { width={1000} /> ); + await screen.findByRole('table'); expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Type' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Tags' })).toBeInTheDocument(); }); - it('displays the data correctly in the table', () => { + it('displays the data correctly in the table', async () => { render( { width={1000} /> ); + await screen.findByRole('table'); const rows = screen.getAllByRole('row'); @@ -134,7 +137,7 @@ describe('SearchResultsTable', () => { jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockEmptySearchResult); }); - it('shows a "No data" message', () => { + it('shows a "No data" message', async () => { render( { width={1000} /> ); + const noData = await screen.findByText('No data'); + expect(noData).toBeInTheDocument(); expect(screen.queryByRole('table', { name: 'Search results table' })).not.toBeInTheDocument(); - expect(screen.getByText('No data')).toBeInTheDocument(); }); }); }); diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx index 1801a8ac5e1..6ab728be20c 100644 --- a/public/app/features/search/page/components/SearchResultsTable.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.tsx @@ -103,6 +103,28 @@ export const SearchResultsTable = React.memo( const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout); + const handleLoadMore = useCallback( + async (startIndex: number, endIndex: number) => { + await response.loadMoreItems(startIndex, endIndex); + + // After we load more items, select them if the "select all" checkbox + // is selected + const isAllSelected = selection?.('*', '*'); + if (!selectionToggle || !selection || !isAllSelected) { + return; + } + + for (let index = startIndex; index < response.view.length; index++) { + const item = response.view.get(index); + const itemIsSelected = selection(item.kind, item.uid); + if (!itemIsSelected) { + selectionToggle(item.kind, item.uid); + } + } + }, + [response, selection, selectionToggle] + ); + const RenderRow = useCallback( ({ index: rowIndex, style }: { index: number; style: CSSProperties }) => { const row = rows[rowIndex]; @@ -164,7 +186,7 @@ export const SearchResultsTable = React.memo( ref={infiniteLoaderRef} isItemLoaded={response.isItemLoaded} itemCount={rows.length} - loadMoreItems={response.loadMoreItems} + loadMoreItems={handleLoadMore} > {({ onItemsRendered, ref }) => ( Date: Thu, 27 Apr 2023 11:06:11 +0100 Subject: [PATCH 456/729] Chore: Don't fail tests on console logs at all during local dev (#67313) --- public/test/setupTests.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index 31f322f1d4a..b345588c355 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -5,9 +5,13 @@ import { initReactI18next } from 'react-i18next'; import { matchers } from './matchers'; -failOnConsole({ - shouldFailOnLog: process.env.CI ? true : false, -}); +if (process.env.CI) { + failOnConsole({ + shouldFailOnLog: true, + shouldFailOnDebug: true, + shouldFailOnInfo: true, + }); +} expect.extend(matchers); From 2dc5872bd68ce996e64551cd757bf8cfa41298c2 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu, 27 Apr 2023 11:11:19 +0100 Subject: [PATCH 457/729] New Dashboard: Fix "build a dashboard" when empty dash page feature is enabled (#66816) * New Dashboard: Fix "build a dashboard" when used with empty dash page feature Closes #66659 --- .../AddPanelButton/AddPanelMenu.tsx | 8 ++++- .../containers/NewDashboardWithDS.tsx | 32 ++++++++++++------- .../dashboard/dashgrid/DashboardEmpty.tsx | 8 ++++- .../app/features/dashboard/state/reducers.ts | 5 +++ .../app/features/dashboard/utils/dashboard.ts | 3 +- public/app/types/dashboard.ts | 3 +- 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.tsx b/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.tsx index f7f69edb45e..4ec49a60bfc 100644 --- a/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.tsx +++ b/public/app/features/dashboard/components/AddPanelButton/AddPanelMenu.tsx @@ -12,6 +12,9 @@ import { onCreateNewRow, onPasteCopiedPanel, } from 'app/features/dashboard/utils/dashboard'; +import { useDispatch, useSelector } from 'app/types'; + +import { setInitialDatasource } from '../../state/reducers'; interface Props { dashboard: DashboardModel; @@ -19,6 +22,8 @@ interface Props { export const AddPanelMenu = ({ dashboard }: Props) => { const copiedPanelPlugin = useMemo(() => getCopiedPanelPlugin(), []); + const dispatch = useDispatch(); + const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); return ( @@ -27,9 +32,10 @@ export const AddPanelMenu = ({ dashboard }: Props) => { label={t('dashboard.add-menu.visualization', 'Visualization')} testId={selectors.components.PageToolbar.itemButton('Add new visualization menu item')} onClick={() => { + const id = onCreateNewPanel(dashboard, initialDatasource); reportInteraction('dashboards_toolbar_add_clicked', { item: 'add_visualization' }); - const id = onCreateNewPanel(dashboard); locationService.partial({ editPanel: id }); + dispatch(setInitialDatasource(undefined)); }} /> ) { const [error, setError] = useState(null); const { datasourceUid } = props.match.params; + const dispatch = useDispatch(); useEffect(() => { const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); @@ -17,19 +20,24 @@ export default function NewDashboardWithDS(props: GrafanaRouteComponentProps<{ d return; } - const newDashboard = getNewDashboardModelData(); - const { dashboard } = newDashboard; - dashboard.panels[0] = { - ...dashboard.panels[0], - datasource: { - uid: ds.uid, - type: ds.type, - }, - }; + if (!config.featureToggles.emptyDashboardPage) { + const newDashboard = getNewDashboardModelData(); + const { dashboard } = newDashboard; + dashboard.panels[0] = { + ...dashboard.panels[0], + datasource: { + uid: ds.uid, + type: ds.type, + }, + }; + + setDashboardToFetchFromLocalStorage(newDashboard); + } else { + dispatch(setInitialDatasource(datasourceUid)); + } - setDashboardToFetchFromLocalStorage(newDashboard); locationService.replace('/dashboard/new'); - }, [datasourceUid]); + }, [datasourceUid, dispatch]); if (error) { return ( diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx index 19070bda0cd..581d8f5e3fe 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx @@ -7,6 +7,9 @@ import { Button, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { DashboardModel } from 'app/features/dashboard/state'; import { onAddLibraryPanel, onCreateNewPanel, onCreateNewRow } from 'app/features/dashboard/utils/dashboard'; +import { useDispatch, useSelector } from 'app/types'; + +import { setInitialDatasource } from '../state/reducers'; export interface Props { dashboard: DashboardModel; @@ -15,6 +18,8 @@ export interface Props { export const DashboardEmpty = ({ dashboard, canCreate }: Props) => { const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); return (
@@ -36,9 +41,10 @@ export const DashboardEmpty = ({ dashboard, canCreate }: Props) => { icon="plus" aria-label="Add new panel" onClick={() => { + const id = onCreateNewPanel(dashboard, initialDatasource); reportInteraction('dashboards_emptydashboard_clicked', { item: 'add_visualization' }); - const id = onCreateNewPanel(dashboard); locationService.partial({ editPanel: id }); + dispatch(setInitialDatasource(undefined)); }} disabled={!canCreate} > diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 03e536c5a68..c047aa9051f 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -14,6 +14,7 @@ export const initialState: DashboardState = { getModel: () => null, permissions: [], initError: null, + initialDatasource: undefined, }; const dashboardSlice = createSlice({ @@ -53,6 +54,9 @@ const dashboardSlice = createSlice({ addPanel: (state, action: PayloadAction) => { //state.panels[action.payload.id] = { pluginId: action.payload.type }; }, + setInitialDatasource: (state, action: PayloadAction) => { + state.initialDatasource = action.payload; + }, }, }); @@ -79,6 +83,7 @@ export const { dashboardInitServices, cleanUpDashboard, addPanel, + setInitialDatasource, } = dashboardSlice.actions; export const dashboardReducer = dashboardSlice.reducer; diff --git a/public/app/features/dashboard/utils/dashboard.ts b/public/app/features/dashboard/utils/dashboard.ts index 3b31aec7c09..bdd342d3c4d 100644 --- a/public/app/features/dashboard/utils/dashboard.ts +++ b/public/app/features/dashboard/utils/dashboard.ts @@ -7,11 +7,12 @@ import store from 'app/core/store'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { calculateNewPanelGridPos } from 'app/features/dashboard/utils/panel'; -export function onCreateNewPanel(dashboard: DashboardModel): number | undefined { +export function onCreateNewPanel(dashboard: DashboardModel, datasource?: string): number | undefined { const newPanel: Partial = { type: 'timeseries', title: 'Panel Title', gridPos: calculateNewPanelGridPos(dashboard), + datasource: datasource ? { uid: datasource } : null, isNew: true, }; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 7ab00843728..f984571ece9 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,5 +1,5 @@ import { DataQuery } from '@grafana/data'; -import { Dashboard } from '@grafana/schema'; +import { Dashboard, DataSourceRef } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardAcl } from './acl'; @@ -101,6 +101,7 @@ export interface QueriesToUpdateOnDashboardLoad { export interface DashboardState { getModel: GetMutableDashboardModelFn; initPhase: DashboardInitPhase; + initialDatasource?: DataSourceRef['uid']; initError: DashboardInitError | null; permissions: DashboardAcl[]; } From 234ed2a6840aa23213012e60613a5fc7c6be6c7c Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Thu, 27 Apr 2023 11:23:17 +0100 Subject: [PATCH 458/729] RBAC: remove RBAC enabled for Alerting (#67274) * WIP * fix from review --- pkg/services/alerting/store_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/services/alerting/store_test.go b/pkg/services/alerting/store_test.go index d81c7f49ce3..b612398922d 100644 --- a/pkg/services/alerting/store_test.go +++ b/pkg/services/alerting/store_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/alerting/models" "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/org" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -49,12 +50,12 @@ func TestIntegrationAlertingDataAccess(t *testing.T) { ss := db.InitTestDB(t) tagService := tagimpl.ProvideService(ss, ss.Cfg) cfg := setting.NewCfg() - cfg.RBACEnabled = false store = &sqlStore{ db: ss, log: log.New(), cfg: cfg, tagService: tagService, + features: featuremgmt.WithFeatures(), } testDash = insertTestDashboard(t, store.db, "dashboard with alerts", 1, 0, false, "alert") @@ -81,7 +82,10 @@ func TestIntegrationAlertingDataAccess(t *testing.T) { setup(t) // Get alert so we can use its ID in tests - alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: &user.SignedInUser{OrgRole: org.RoleAdmin}} + signedInUser := &user.SignedInUser{ + OrgRole: org.RoleAdmin, + } + alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: signedInUser} result, err2 := store.HandleAlertsQuery(context.Background(), &alertQuery) require.Nil(t, err2) @@ -159,7 +163,13 @@ func TestIntegrationAlertingDataAccess(t *testing.T) { t.Run("Viewer can read alerts", func(t *testing.T) { setup(t) - viewerUser := &user.SignedInUser{OrgRole: org.RoleViewer, OrgID: 1} + viewerUser := &user.SignedInUser{ + OrgRole: org.RoleViewer, + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}, + }, + } alertQuery := models.GetAlertsQuery{DashboardIDs: []int64{testDash.ID}, PanelID: 1, OrgID: 1, User: viewerUser} res, err2 := store.HandleAlertsQuery(context.Background(), &alertQuery) From 33034280836b925b25f6135c9db3f475795af626 Mon Sep 17 00:00:00 2001 From: Ieva Date: Thu, 27 Apr 2023 11:35:39 +0100 Subject: [PATCH 459/729] Provisioning: Fix provisioning issues with legacy alerting and data source permissions (#67308) extend provisioner permissions --- pkg/services/dashboards/service/dashboard_service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 1dd16df8d06..7d565f6a583 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" "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/folder" "github.com/grafana/grafana/pkg/services/guardian" @@ -29,6 +30,7 @@ var ( {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll}, {Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll}, {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeFoldersAll}, + {Action: datasources.ActionRead, Scope: datasources.ScopeAll}, } // DashboardServiceImpl implements the DashboardService interface _ dashboards.DashboardService = (*DashboardServiceImpl)(nil) From f28c962dc83f534b5eb5752d827c15603b8e2970 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Thu, 27 Apr 2023 12:40:38 +0200 Subject: [PATCH 460/729] Logs: Add documentation for Log Context (#67282) * add documentation for log context * Update docs/sources/explore/logs-integration.md Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * Update docs/sources/explore/logs-integration.md Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * Update docs/sources/explore/logs-integration.md Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * add bold for buttons --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --- docs/sources/explore/logs-integration.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index af9c3c4c194..9a1c63b990e 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -118,6 +118,16 @@ Explore replaces these sequences. When it does so, the option will change from " By using data links, you can turn any part of a log message into an internal or external link. The created link is visible as a button in the **Links** section inside the **Log details** view. {{< figure src="/static/img/docs/explore/data-link-9-4.png" max-width="800px" caption="Data link in Explore" >}} +## Log context + +Log context is a feature that allows you to display additional lines of context surrounding a log entry that matches a particular search query. This can be helpful in understanding the log entry's context, and is similar to the `-C` parameter in the `grep` command. + +When using Log context in Grafana, you can configure the number of lines of context to display before and after the matching log entry. By default, the Log context feature will show the log entry itself along with the 50 lines before and after it. However, this can be adjusted as needed depending on the specific use case. + +You may encounter long lines of text that make it difficult to read and analyze the context around each log entry. This is where the **Wrap lines** toggle can come in handy. By enabling this toggle, Grafana will automatically wrap long lines of text so that they fit within the visible width of the viewer. This can make it easier to read and understand the log entries. + +The **Open in split view** button allows you to execute the context query for a log entry in a split screen in the Explore view. Clicking this button will open a new Explore pane with the context query displayed alongside the log entry, making it easier to analyze and understand the surrounding context. + ## Toggle field visibility Expand a log line and click the eye icon to show or hide fields. From 4b047b62a7d5230cf01b7b16046d26f501532e44 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 27 Apr 2023 12:43:20 +0200 Subject: [PATCH 461/729] NestedFolders: Button for creating new dashboards and and folders (#67260) * Add CreateNewButton and tests * Add translation support * Move reused phrases to temp common file * Use just a simple button --------- Co-authored-by: joshhunt --- .../BrowseDashboardsPage.tsx | 3 +- .../components/CreateNewButton.test.tsx | 29 ++++++++++++ .../components/CreateNewButton.tsx | 45 +++++++++++++++++++ .../search/components/DashboardActions.tsx | 10 ++--- public/app/features/search/tempI18nPhrases.ts | 16 +++++++ 5 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/CreateNewButton.test.tsx create mode 100644 public/app/features/browse-dashboards/components/CreateNewButton.tsx diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 336e109215f..72fdbe3903b 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -15,6 +15,7 @@ import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI'; import { BrowseActions } from './components/BrowseActions/BrowseActions'; import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; +import { CreateNewButton } from './components/CreateNewButton'; import { SearchView } from './components/SearchView'; import { useHasSelection } from './state'; @@ -49,7 +50,7 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { const hasSelection = useHasSelection(); return ( - + }> ); + const newButton = screen.getByText('New'); + await userEvent.click(newButton); +} + +describe('NewActionsButton', () => { + it('should display the correct urls with a given folderUID', async () => { + await renderAndOpen('123'); + + expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new?folderUid=123'); + expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new?folderUid=123'); + expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import?folderUid=123'); + }); + + it('should display urls without params when there is no folderUID', async () => { + await renderAndOpen(); + + expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new'); + expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new'); + expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import'); + }); +}); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx new file mode 100644 index 00000000000..01e32bf8fbb --- /dev/null +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Button, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { + getNewDashboardPhrase, + getNewFolderPhrase, + getImportPhrase, + getNewPhrase, +} from 'app/features/search/tempI18nPhrases'; + +interface Props { + /** + * Pass a folder UID in which the dashboard or folder will be created + */ + inFolder?: string; +} + +export function CreateNewButton({ inFolder }: Props) { + const newMenu = ( + + + + + + ); + + return ( + + + + ); +} + +/** + * + * @param url without any parameters + * @param folderUid folder id + * @returns url with paramter if folder is present + */ +function addFolderUidToUrl(url: string, folderUid: string | undefined) { + return folderUid ? url + '?folderUid=' + folderUid : url; +} diff --git a/public/app/features/search/components/DashboardActions.tsx b/public/app/features/search/components/DashboardActions.tsx index 40ee16db4f7..12dc125b50e 100644 --- a/public/app/features/search/components/DashboardActions.tsx +++ b/public/app/features/search/components/DashboardActions.tsx @@ -2,10 +2,10 @@ import React, { useMemo, useState } from 'react'; import { config, reportInteraction } from '@grafana/runtime'; import { Menu, Dropdown, Button, Icon, HorizontalGroup } from '@grafana/ui'; -import { t } from 'app/core/internationalization'; import { FolderDTO } from 'app/types'; import { MoveToFolderModal } from '../page/components/MoveToFolderModal'; +import { getImportPhrase, getNewDashboardPhrase, getNewFolderPhrase, getNewPhrase } from '../tempI18nPhrases'; export interface Props { folder: FolderDTO | undefined; @@ -43,7 +43,7 @@ export const DashboardActions = ({ folder, canCreateFolders = false, canCreateDa {canCreateDashboards && ( reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new'), from: '/dashboards' }) } @@ -52,7 +52,7 @@ export const DashboardActions = ({ folder, canCreateFolders = false, canCreateDa {canCreateFolders && (config.featureToggles.nestedFolders || !folder?.uid) && ( reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new_folder'), from: '/dashboards' }) } @@ -61,7 +61,7 @@ export const DashboardActions = ({ folder, canCreateFolders = false, canCreateDa {canCreateDashboards && ( reportInteraction('grafana_menu_item_clicked', { url: actionUrl('import'), from: '/dashboards' }) } @@ -82,7 +82,7 @@ export const DashboardActions = ({ folder, canCreateFolders = false, canCreateDa )} diff --git a/public/app/features/search/tempI18nPhrases.ts b/public/app/features/search/tempI18nPhrases.ts index 07c2abbf0ef..83b99bf2570 100644 --- a/public/app/features/search/tempI18nPhrases.ts +++ b/public/app/features/search/tempI18nPhrases.ts @@ -8,3 +8,19 @@ export function getSearchPlaceholder(includePanels = false) { ? t('search.search-input.include-panels-placeholder', 'Search for dashboards, folders, and panels') : t('search.search-input.placeholder', 'Search for dashboards and folders'); } + +export function getNewDashboardPhrase() { + return t('search.dashboard-actions.new-dashboard', 'New Dashboard'); +} + +export function getNewFolderPhrase() { + return t('search.dashboard-actions.new-folder', 'New Folder'); +} + +export function getImportPhrase() { + return t('search.dashboard-actions.import', 'Import'); +} + +export function getNewPhrase() { + return t('search.dashboard-actions.new', 'New'); +} From a3d31e04202d329fedd1e99cfab5aaa2c11faf46 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Thu, 27 Apr 2023 12:44:16 +0200 Subject: [PATCH 462/729] Loki: Fix margin in Log Context (#67299) * remove top margin * also fix for parsed labels --- .../plugins/datasource/loki/components/LokiContextUi.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx index 73bfc016bbd..d6c8a216d2a 100644 --- a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx @@ -43,7 +43,12 @@ function getStyles(theme: GrafanaTheme2) { `, label: css` max-width: 100%; - margin: ${theme.spacing(2)} 0; + &:first-of-type { + margin-bottom: ${theme.spacing(2)}; + } + &:not(:first-of-type) { + margin: ${theme.spacing(2)} 0; + } `, query: css` text-align: start; From 2306fb38dc6b791bc7a9f669027811309d038cf1 Mon Sep 17 00:00:00 2001 From: Khushi Jain <57278642+khushijain21@users.noreply.github.com> Date: Thu, 27 Apr 2023 16:42:51 +0530 Subject: [PATCH 463/729] Search: Preserves search filters when navigating to another page (#67021) --- public/app/features/search/state/SearchStateManager.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/public/app/features/search/state/SearchStateManager.ts b/public/app/features/search/state/SearchStateManager.ts index 47a57ade1ed..104511ad2e0 100644 --- a/public/app/features/search/state/SearchStateManager.ts +++ b/public/app/features/search/state/SearchStateManager.ts @@ -258,11 +258,6 @@ export class SearchStateManager extends StateManagerBase { * When item is selected clear some filters and report interaction */ onSearchItemClicked = (e: React.MouseEvent) => { - // Clear some filters only if we're not opening a search item in a new tab - if (!e.altKey && !e.ctrlKey && !e.metaKey) { - this.setState({ tag: [], starred: false, sort: undefined, query: '', folderUid: undefined }); - } - reportSearchResultInteraction(this.state.eventTrackingNamespace, { layout: this.state.layout, starred: this.state.starred, From cefeef71348cd13d8fee12e7cbdb3990ba0dcd81 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Thu, 27 Apr 2023 13:30:11 +0200 Subject: [PATCH 464/729] Proxy: Improve header handling for reverse proxy (#67279) --- pkg/util/proxyutil/proxyutil.go | 11 +++++++++++ pkg/util/proxyutil/reverse_proxy.go | 21 ++++++++++++++++++++- pkg/util/proxyutil/reverse_proxy_test.go | 7 ++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/pkg/util/proxyutil/proxyutil.go b/pkg/util/proxyutil/proxyutil.go index 68ae725919a..6cccf55e37d 100644 --- a/pkg/util/proxyutil/proxyutil.go +++ b/pkg/util/proxyutil/proxyutil.go @@ -1,6 +1,7 @@ package proxyutil import ( + "fmt" "net" "net/http" "sort" @@ -75,6 +76,16 @@ func SetProxyResponseHeaders(header http.Header) { header.Set("Content-Security-Policy", "sandbox") } +// SetViaHeader adds Grafana's reverse proxy to the proxy chain. +// Defined in RFC 9110 7.6.3 https://datatracker.ietf.org/doc/html/rfc9110#name-via +func SetViaHeader(header http.Header, major, minor int) { + via := fmt.Sprintf("%d.%d grafana", major, minor) + if old := header.Get("Via"); old != "" { + via = fmt.Sprintf("%s, %s", via, old) + } + header.Set("Via", via) +} + // ApplyUserHeader Set the X-Grafana-User header if needed (and remove if not). func ApplyUserHeader(sendUserHeader bool, req *http.Request, user *user.SignedInUser) { req.Header.Del(UserHeaderName) diff --git a/pkg/util/proxyutil/reverse_proxy.go b/pkg/util/proxyutil/reverse_proxy.go index 668015edf15..fd3c8ea95ec 100644 --- a/pkg/util/proxyutil/reverse_proxy.go +++ b/pkg/util/proxyutil/reverse_proxy.go @@ -79,11 +79,30 @@ func wrapDirector(d func(*http.Request)) func(req *http.Request) { } } +// deletedHeaders lists a number of headers that we don't want to +// pass-through from the upstream when using a reverse proxy. +// +// These are related to the connection between Grafana and the proxy +// or instructions that would alter how a browser will interact with +// future requests to Grafana (such as enabling Strict Transport +// Security) +var deletedHeaders = []string{ + "Alt-Svc", + "Close", + "Server", + "Set-Cookie", + "Strict-Transport-Security", +} + // modifyResponse enforces certain constraints on http.Response. func modifyResponse(logger glog.Logger) func(resp *http.Response) error { return func(resp *http.Response) error { - resp.Header.Del("Set-Cookie") + for _, header := range deletedHeaders { + resp.Header.Del(header) + } + SetProxyResponseHeaders(resp.Header) + SetViaHeader(resp.Header, resp.ProtoMajor, resp.ProtoMinor) return nil } } diff --git a/pkg/util/proxyutil/reverse_proxy_test.go b/pkg/util/proxyutil/reverse_proxy_test.go index 602575d660f..ada4609f981 100644 --- a/pkg/util/proxyutil/reverse_proxy_test.go +++ b/pkg/util/proxyutil/reverse_proxy_test.go @@ -21,6 +21,8 @@ func TestReverseProxy(t *testing.T) { upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { actualReq = req http.SetCookie(w, &http.Cookie{Name: "test"}) + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + w.Header().Set("X-Custom-Hdr", "Ok!") w.WriteHeader(http.StatusOK) })) t.Cleanup(upstream.Close) @@ -52,11 +54,14 @@ func TestReverseProxy(t *testing.T) { require.Empty(t, actualReq.Header.Get("Referer")) require.Equal(t, "https://test.com/api", actualReq.Header.Get("X-Grafana-Referer")) require.Equal(t, "value", actualReq.Header.Get("X-KEY")) + require.Empty(t, actualReq.Header.Get("Authorization")) resp := rec.Result() require.Empty(t, resp.Cookies()) require.Equal(t, "sandbox", resp.Header.Get("Content-Security-Policy")) + require.Contains(t, resp.Header, "X-Custom-Hdr") + require.NotContains(t, resp.Header, "Strict-Transport-Security") + require.Contains(t, resp.Header.Get("Via"), "grafana") require.NoError(t, resp.Body.Close()) - require.Empty(t, actualReq.Header.Get("Authorization")) }) t.Run("When proxying a request using WithModifyResponse should call it before default ModifyResponse func", func(t *testing.T) { From 6e950ca62a4dadbd392f01a95b8513fd237ffcfe Mon Sep 17 00:00:00 2001 From: dsotirakis Date: Mon, 20 Mar 2023 09:51:29 +0200 Subject: [PATCH 465/729] Geomap: Sanitize the attribution string (#745) * SAML: Update grafana/saml library (#691) Co-authored-by: jguer * SVG: Add dompurify preprocessor step (#698) * add sanitized SVG component * add sanitize * Fix frontend build * Remove unnecessary yarn.lock changes * Fix formatting * Re-add yarn.lock message as I guess it is needed --------- Co-authored-by: dsotirakis Co-authored-by: jguer Co-authored-by: nmarrs Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> --- go.mod | 2 +- public/app/plugins/panel/geomap/utils/layers.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 035d3c70e2d..065008ae58c 100644 --- a/go.mod +++ b/go.mod @@ -123,7 +123,7 @@ require ( gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 - xorm.io/builder v0.3.6 // indirect + xorm.io/builder v0.3.6 xorm.io/core v0.7.3 xorm.io/xorm v0.8.2 ) diff --git a/public/app/plugins/panel/geomap/utils/layers.ts b/public/app/plugins/panel/geomap/utils/layers.ts index 6e9d5995aa7..75a2357c388 100644 --- a/public/app/plugins/panel/geomap/utils/layers.ts +++ b/public/app/plugins/panel/geomap/utils/layers.ts @@ -2,7 +2,7 @@ import { Map as OpenLayersMap } from 'ol'; import { FeatureLike } from 'ol/Feature'; import { Subject } from 'rxjs'; -import { getFrameMatchers, MapLayerHandler, MapLayerOptions, PanelData } from '@grafana/data/src'; +import { getFrameMatchers, MapLayerHandler, MapLayerOptions, PanelData, textUtil } from '@grafana/data'; import { config } from '@grafana/runtime/src'; import { GeomapPanel } from '../GeomapPanel'; @@ -114,6 +114,10 @@ export async function initLayer( return Promise.reject('unknown layer: ' + options.type); } + if (options.config?.attribution) { + options.config.attribution = textUtil.sanitizeTextPanelContent(options.config.attribution); + } + const handler = await item.create(map, options, panel.props.eventBus, config.theme2); const layer = handler.init(); // eslint-disable-line if (options.opacity != null) { From 96fdbbee90d55788f262233f2277e55e5caa794f Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 3 Apr 2023 12:49:11 +0100 Subject: [PATCH 466/729] AuthJWT: Fix JWT query param leak (CVE-2023-1387) (#825) fix JWT query param leak Co-authored-by: Gabriel MABILLE Co-authored-by: Kalle Persson --- pkg/services/authn/clients/jwt.go | 15 +++++++++ pkg/services/authn/clients/jwt_test.go | 44 +++++++++++++++++++++++++ pkg/services/contexthandler/auth_jwt.go | 24 +++++++++++--- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index a6474ba7176..cc65b7e4ed2 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -19,6 +19,8 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) +const authQueryParamName = "auth_token" + var _ authn.ContextAwareClient = new(JWT) var ( @@ -50,6 +52,7 @@ func (s *JWT) Name() string { func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { jwtToken := s.retrieveToken(r.HTTPRequest) + s.stripSensitiveParam(r.HTTPRequest) claims, err := s.jwtService.Verify(ctx, jwtToken) if err != nil { @@ -120,6 +123,18 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi return id, nil } +// remove sensitive query param +// avoid JWT URL login passing auth_token in URL +func (s *JWT) stripSensitiveParam(httpRequest *http.Request) { + if s.cfg.JWTAuthURLLogin { + params := httpRequest.URL.Query() + if params.Has(authQueryParamName) { + params.Del(authQueryParamName) + httpRequest.URL.RawQuery = params.Encode() + } + } +} + // retrieveToken retrieves the JWT token from the request. func (s *JWT) retrieveToken(httpRequest *http.Request) string { jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName) diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index 04a4c837bfe..f59aac88d8e 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -307,3 +307,47 @@ func TestJWTTest(t *testing.T) { }) } } + +func TestJWTStripParam(t *testing.T) { + jwtService := &jwt.FakeJWTService{ + VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { + return jwt.JWTClaims{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "Admin", + }, nil + }, + } + + jwtHeaderName := "X-Forwarded-User" + + cfg := &setting.Cfg{ + JWTAuthEnabled: true, + JWTAuthHeaderName: jwtHeaderName, + JWTAuthAutoSignUp: true, + JWTAuthAllowAssignGrafanaAdmin: true, + JWTAuthURLLogin: true, + JWTAuthRoleAttributeStrict: false, + JWTAuthRoleAttributePath: "roles", + JWTAuthEmailClaim: "email", + JWTAuthUsernameClaim: "preferred_username", + } + + // #nosec G101 -- This is a dummy/test token + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o" + + httpReq := &http.Request{ + URL: &url.URL{RawQuery: "auth_token=" + token + "&other_param=other_value"}, + } + jwtClient := ProvideJWT(jwtService, cfg) + _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: httpReq, + Resp: nil, + }) + require.NoError(t, err) + // auth_token should be removed from the query string + assert.Equal(t, "other_param=other_value", httpReq.URL.RawQuery) +} diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index 9936d956c96..1d3d79640c3 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -15,12 +15,14 @@ import ( loginsvc "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) const ( - InvalidJWT = "Invalid JWT" - InvalidRole = "Invalid Role" - UserNotFound = "User not found" + InvalidJWT = "Invalid JWT" + InvalidRole = "Invalid Role" + UserNotFound = "User not found" + authQueryParamName = "auth_token" ) func (h *ContextHandler) initContextWithJWT(ctx *contextmodel.ReqContext, orgId int64) bool { @@ -30,13 +32,15 @@ func (h *ContextHandler) initContextWithJWT(ctx *contextmodel.ReqContext, orgId jwtToken := ctx.Req.Header.Get(h.Cfg.JWTAuthHeaderName) if jwtToken == "" && h.Cfg.JWTAuthURLLogin { - jwtToken = ctx.Req.URL.Query().Get("auth_token") + jwtToken = ctx.Req.URL.Query().Get(authQueryParamName) } if jwtToken == "" { return false } + stripSensitiveParam(h.Cfg, ctx.Req) + // Strip the 'Bearer' prefix if it exists. jwtToken = strings.TrimPrefix(jwtToken, "Bearer ") @@ -204,3 +208,15 @@ func searchClaimsForStringAttr(attributePath string, claims map[string]interface return "", nil } + +// remove sensitive query params +// avoid JWT URL login passing auth_token in URL +func stripSensitiveParam(cfg *setting.Cfg, httpRequest *http.Request) { + if cfg.JWTAuthURLLogin { + params := httpRequest.URL.Query() + if params.Has(authQueryParamName) { + params.Del(authQueryParamName) + httpRequest.URL.RawQuery = params.Encode() + } + } +} From e17737ba872987aa837510e52f7b0c584e1a260b Mon Sep 17 00:00:00 2001 From: Misi Date: Mon, 3 Apr 2023 14:23:52 +0200 Subject: [PATCH 467/729] Chore: Update SAML lib (#824) Update saml lib --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 065008ae58c..5ec41b905c9 100644 --- a/go.mod +++ b/go.mod @@ -123,7 +123,7 @@ require ( gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 - xorm.io/builder v0.3.6 + xorm.io/builder v0.3.6 // indirect xorm.io/core v0.7.3 xorm.io/xorm v0.8.2 ) @@ -401,7 +401,7 @@ require ( ) // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream -replace github.com/crewjam/saml => github.com/grafana/saml v0.4.13-0.20230203140620-5f476db5c00a +replace github.com/crewjam/saml => github.com/grafana/saml v0.4.13-0.20230331080031-67cbfa09c7b6 // Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0. // Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593 diff --git a/go.sum b/go.sum index 727940e1869..76dbc1af720 100644 --- a/go.sum +++ b/go.sum @@ -1302,8 +1302,8 @@ github.com/grafana/phlare/api v0.1.4-0.20230426005640-f90edba05413 h1:bBzCezZNRy github.com/grafana/phlare/api v0.1.4-0.20230426005640-f90edba05413/go.mod h1:IvwuGG9xa/h96UH/exgvsfy3zE+ZpctkNT9o5aaGdrU= github.com/grafana/prometheus-alertmanager v0.25.1-0.20230308154952-78fedf89728b h1:VQOGGGJ2lKcVPANyzIESKYhSeA0QIvUQwfA3CbrkDfA= github.com/grafana/prometheus-alertmanager v0.25.1-0.20230308154952-78fedf89728b/go.mod h1:MnBfDPXJqXmmfPwQlCLvVUdqfnvrAw+hSPtDeaaFwj4= -github.com/grafana/saml v0.4.13-0.20230203140620-5f476db5c00a h1:aWSTt/pTOI4uGY9DhBMG1l0GOnGjIYtaqxzYR3/q82o= -github.com/grafana/saml v0.4.13-0.20230203140620-5f476db5c00a/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA= +github.com/grafana/saml v0.4.13-0.20230331080031-67cbfa09c7b6 h1:oHn/OOUkECNX06DPHksS7R3UY5Qdye04b/sBj2/OJ5E= +github.com/grafana/saml v0.4.13-0.20230331080031-67cbfa09c7b6/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA= github.com/grafana/sqlds/v2 v2.3.10 h1:HWKhE0vR6LoEiE+Is8CSZOgaB//D1yqb2ntkass9Fd4= github.com/grafana/sqlds/v2 v2.3.10/go.mod h1:c6ibxnxRVGxV/0YkEgvy7QpQH/lyifFyV7K/14xvdIs= github.com/grafana/thema v0.0.0-20230417103609-99b482c479fe h1:Ws23A0XH6XYNaF/XhrOhNiC09rqGisvflCf0aHRhpTM= From d01ea9902c9cacab2758ff902d516f65987c0ba1 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Thu, 27 Apr 2023 15:07:27 +0300 Subject: [PATCH 468/729] fix add column btn styling (#67369) --- public/app/plugins/panel/datagrid/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/panel/datagrid/utils.ts b/public/app/plugins/panel/datagrid/utils.ts index 3545dc3af86..ab14d01f809 100644 --- a/public/app/plugins/panel/datagrid/utils.ts +++ b/public/app/plugins/panel/datagrid/utils.ts @@ -266,7 +266,7 @@ export const getStyles = (theme: GrafanaTheme2, isResizeInProgress: boolean) => transition: background-color 200ms; cursor: pointer; :hover { - background-color: ${theme.colors.secondary.shade}; + background-color: ${theme.colors.background.secondary}; } } input { From 3a8bb226bdd433f5e5db826be3bafa084fbd6137 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 27 Apr 2023 14:09:20 +0200 Subject: [PATCH 469/729] Logs: Use millisecond precision for open context in split view (#67385) Log: Use millisecond precision for open context in split view --- .../log-context/LogRowContextModal.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index bcfec8fe92b..411b725c2fc 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -12,7 +12,8 @@ import { LogsDedupStrategy, LogsSortOrder, SelectableValue, - rangeUtil, + dateTime, + TimeRange, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; @@ -23,6 +24,7 @@ import { splitOpen } from 'app/features/explore/state/main'; import { SETTINGS_KEYS } from 'app/features/explore/utils/logs'; import { useDispatch } from 'app/types'; +import { sortLogRows } from '../../utils'; import { LogRows } from '../LogRows'; import { LoadMoreOptions, LogContextButtons } from './LogContextButtons'; @@ -154,16 +156,26 @@ export const LogRowContextModal: React.FunctionComponent { const { before, after } = context; - const allRows = [...before, row, ...after].sort((a, b) => a.timeEpochMs - b.timeEpochMs); - const first = allRows[0]; - const last = allRows[allRows.length - 1]; - return rangeUtil.convertRawToRange( - { - from: first.timeUtc, - to: last.timeUtc, + const allRows = sortLogRows([...before, row, ...after], LogsSortOrder.Ascending); + const fromMs = allRows[0].timeEpochMs; + let toMs = allRows[allRows.length - 1].timeEpochMs; + // In case we have a lot of logs and from and to have same millisecond + // we add 1 millisecond to toMs to make sure we have a range + if (fromMs === toMs) { + toMs += 1; + } + const from = dateTime(fromMs); + const to = dateTime(toMs); + + const range: TimeRange = { + from, + to, + raw: { + from, + to, }, - 'utc' - ); + }; + return range; }, [context, row]); const onChangeLimitOption = (option: SelectableValue) => { From 886b91eca55d8e380a3068461ecb8640e1c5e4ca Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu, 27 Apr 2023 13:28:19 +0100 Subject: [PATCH 470/729] Tracing: Next/prev text for span filters (#67208) * Next/prev text * Tests * Update test * Updated state vars and also made other improvements --- .../features/explore/TraceView/TraceView.tsx | 1 - .../TraceView/TraceViewContainer.test.tsx | 50 ++++++++++ .../NewTracePageHeader.test.tsx | 1 - .../TracePageHeader/NewTracePageHeader.tsx | 3 - .../NewTracePageSearchBar.test.tsx | 99 +++++++++++++++---- .../TracePageHeader/NewTracePageSearchBar.tsx | 72 ++++++++++---- .../SpanFilters/SpanFilters.test.tsx | 5 +- .../SpanFilters/SpanFilters.tsx | 65 +++++------- 8 files changed, 203 insertions(+), 93 deletions(-) diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 2c2049d0974..3de2d2da2ca 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -166,7 +166,6 @@ export function TraceView(props: Props) { setShowSpanFilters={setShowSpanFilters} showSpanFilterMatchesOnly={showSpanFilterMatchesOnly} setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly} - focusedSpanIdForSearch={newTraceViewHeaderFocusedSpanIdForSearch} setFocusedSpanIdForSearch={setNewTraceViewHeaderFocusedSpanIdForSearch} spanFilterMatches={spanFilterMatches} datasourceType={datasourceType} diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index 86677a298e6..367d1176956 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -134,6 +134,56 @@ describe('TraceViewContainer', () => { ).toContain('rowFocused'); }); + it('can select next/prev results', async () => { + config.featureToggles.newTraceViewHeader = true; + renderTraceViewContainer(); + const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' }); + await user.click(spanFiltersButton); + + const nextResultButton = screen.getByRole('button', { name: 'Next result button' }); + const prevResultButton = screen.getByRole('button', { name: 'Prev result button' }); + expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(true); + expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(true); + + await user.click(screen.getByLabelText('Select tag key')); + const tagOption = screen.getByText('component'); + await waitFor(() => expect(tagOption).toBeInTheDocument()); + await user.click(tagOption); + + await waitFor(() => { + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className + ).toContain('rowMatchingFilter'); + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className + ).toContain('rowMatchingFilter'); + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className + ).toContain('rowMatchingFilter'); + }); + + expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(false); + expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(false); + await user.click(nextResultButton); + await waitFor(() => { + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className + ).toContain('rowFocused'); + }); + await user.click(nextResultButton); + await waitFor(() => { + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className + ).toContain('rowFocused'); + }); + await user.click(prevResultButton); + await waitFor(() => { + expect( + screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className + ).toContain('rowFocused'); + }); + }); + it('show matches only works as expected', async () => { config.featureToggles.newTraceViewHeader = true; renderTraceViewContainer(); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx index eec1a45a7b2..05b7ad0bee8 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.test.tsx @@ -33,7 +33,6 @@ const setup = () => { showSpanFilterMatchesOnly: false, setShowSpanFilterMatchesOnly: jest.fn(), spanFilterMatches: undefined, - focusedSpanIdForSearch: '', setFocusedSpanIdForSearch: jest.fn(), datasourceType: 'tempo', setHeaderHeight: jest.fn(), diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx index 1e0ffdcbb9f..60c0e40a72d 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageHeader.tsx @@ -41,7 +41,6 @@ export type TracePageHeaderProps = { setShowSpanFilters: (isOpen: boolean) => void; showSpanFilterMatchesOnly: boolean; setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; - focusedSpanIdForSearch: string; setFocusedSpanIdForSearch: React.Dispatch>; spanFilterMatches: Set | undefined; datasourceType: string; @@ -58,7 +57,6 @@ export const NewTracePageHeader = memo((props: TracePageHeaderProps) => { setShowSpanFilters, showSpanFilterMatchesOnly, setShowSpanFilterMatchesOnly, - focusedSpanIdForSearch, setFocusedSpanIdForSearch, spanFilterMatches, datasourceType, @@ -140,7 +138,6 @@ export const NewTracePageHeader = memo((props: TracePageHeaderProps) => { search={search} setSearch={setSearch} spanFilterMatches={spanFilterMatches} - focusedSpanIdForSearch={focusedSpanIdForSearch} setFocusedSpanIdForSearch={setFocusedSpanIdForSearch} datasourceType={datasourceType} /> diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx index 4515b0bbbeb..ea43b80c2f1 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.test.tsx @@ -12,25 +12,49 @@ // See the License for the specific language governing permissions and // limitations under the License. -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 { defaultFilters } from '../../useSearch'; -import NewTracePageSearchBar, { TracePageSearchBarProps } from './NewTracePageSearchBar'; - -const defaultProps = { - search: defaultFilters, - setFocusedSpanIdForSearch: jest.fn(), - showSpanFilterMatchesOnly: false, - setShowSpanFilterMatchesOnly: jest.fn(), -}; +import NewTracePageSearchBar, { getStyles } from './NewTracePageSearchBar'; describe('', () => { + let user: ReturnType; + beforeEach(() => { + jest.useFakeTimers(); + // Need to use delay: null here to work with fakeTimers + // see https://github.com/testing-library/user-event/issues/833 + user = userEvent.setup({ delay: null }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + const NewTracePageSearchBarWithProps = (props: { matches: string[] | undefined }) => { + const searchBarProps = { + search: defaultFilters, + spanFilterMatches: props.matches ? new Set(props.matches) : undefined, + showSpanFilterMatchesOnly: false, + setShowSpanFilterMatchesOnly: jest.fn(), + setFocusedSpanIdForSearch: jest.fn(), + datasourceType: '', + reset: jest.fn(), + totalSpans: 100, + }; + + return ; + }; + + it('should render', () => { + expect(() => render()).not.toThrow(); + }); + it('renders buttons', () => { - render(); - const nextResButton = screen.getByRole('button', { name: 'Next result button' }); - const prevResButton = screen.getByRole('button', { name: 'Prev result button' }); + render(); + const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); + const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); const resetFiltersButton = screen.getByRole('button', { name: 'Reset filters button' }); expect(nextResButton).toBeInTheDocument(); expect(prevResButton).toBeInTheDocument(); @@ -40,22 +64,55 @@ describe('', () => { expect((resetFiltersButton as HTMLButtonElement)['disabled']).toBe(true); }); - it('renders buttons that can be used to search if results found', () => { - const props = { - ...defaultProps, - spanFilterMatches: new Set(['2ed38015486087ca']), - }; - render(); - const nextResButton = screen.getByRole('button', { name: 'Next result button' }); - const prevResButton = screen.getByRole('button', { name: 'Prev result button' }); + it('renders total spans', async () => { + render(); + expect(screen.getByText('100 spans')).toBeDefined(); + }); + + it('renders buttons that can be used to search if filters added', () => { + render(); + const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); + const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); expect(nextResButton).toBeInTheDocument(); expect(prevResButton).toBeInTheDocument(); expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false); expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false); + expect(screen.getByText('1 match')).toBeDefined(); + }); + + it('renders correctly when moving through matches', async () => { + render(); + const nextResButton = screen.queryByRole('button', { name: 'Next result button' }); + const prevResButton = screen.queryByRole('button', { name: 'Prev result button' }); + expect(screen.getByText('3 matches')).toBeDefined(); + await user.click(nextResButton!); + expect(screen.getByText('1/3 matches')).toBeDefined(); + await user.click(nextResButton!); + expect(screen.getByText('2/3 matches')).toBeDefined(); + await user.click(nextResButton!); + expect(screen.getByText('3/3 matches')).toBeDefined(); + await user.click(nextResButton!); + expect(screen.getByText('1/3 matches')).toBeDefined(); + await user.click(prevResButton!); + expect(screen.getByText('3/3 matches')).toBeDefined(); + await user.click(prevResButton!); + expect(screen.getByText('2/3 matches')).toBeDefined(); + }); + + it('renders correctly when there are no matches i.e. too many filters added', async () => { + const { container } = render(); + const styles = getStyles(); + const tooltip = container.querySelector('.' + styles.matchesTooltip); + expect(screen.getByText('0 matches')).toBeDefined(); + userEvent.hover(tooltip!); + jest.advanceTimersByTime(1000); + await waitFor(() => { + expect(screen.getByText(/0 span matches for the filters selected/)).toBeDefined(); + }); }); it('renders show span filter matches only switch', async () => { - render(); + render(); const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' }); expect(matchesSwitch).toBeInTheDocument(); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx index bf0ddd18a8b..ce3d44da620 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/NewTracePageSearchBar.tsx @@ -13,42 +13,50 @@ // limitations under the License. import { css } from '@emotion/css'; -import React, { memo, Dispatch, SetStateAction, useEffect, useMemo } from 'react'; +import React, { memo, Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; import { config, reportInteraction } from '@grafana/runtime'; -import { Button, Switch, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Switch, Tooltip, useStyles2 } from '@grafana/ui'; import { SearchProps } from '../../useSearch'; import { convertTimeFilter } from '../utils/filter-spans'; export type TracePageSearchBarProps = { search: SearchProps; - setSearch: React.Dispatch>; spanFilterMatches: Set | undefined; showSpanFilterMatchesOnly: boolean; setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void; - focusedSpanIdForSearch: string; setFocusedSpanIdForSearch: Dispatch>; datasourceType: string; reset: () => void; + totalSpans: number; }; export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) { const { search, spanFilterMatches, - focusedSpanIdForSearch, + showSpanFilterMatchesOnly, + setShowSpanFilterMatchesOnly, setFocusedSpanIdForSearch, datasourceType, reset, - showSpanFilterMatchesOnly, - setShowSpanFilterMatchesOnly, + totalSpans, } = props; + const [currentSpanIndex, setCurrentSpanIndex] = useState(-1); const styles = useStyles2(getStyles); useEffect(() => { + setCurrentSpanIndex(-1); setFocusedSpanIdForSearch(''); - }, [search, setFocusedSpanIdForSearch]); + }, [setFocusedSpanIdForSearch, spanFilterMatches]); + + useEffect(() => { + if (spanFilterMatches) { + const spanMatches = Array.from(spanFilterMatches!); + setFocusedSpanIdForSearch(spanMatches[currentSpanIndex]); + } + }, [currentSpanIndex, setFocusedSpanIdForSearch, spanFilterMatches]); const nextResult = () => { reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { @@ -57,17 +65,14 @@ export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProp direction: 'next', }); - const spanMatches = Array.from(spanFilterMatches!); - const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch); - // new query || at end, go to start - if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) { - setFocusedSpanIdForSearch(spanMatches[0]); + if (currentSpanIndex === -1 || (spanFilterMatches && currentSpanIndex === spanFilterMatches.size - 1)) { + setCurrentSpanIndex(0); return; } // get next - setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]); + setCurrentSpanIndex(currentSpanIndex + 1); }; const prevResult = () => { @@ -77,19 +82,17 @@ export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProp direction: 'prev', }); - const spanMatches = Array.from(spanFilterMatches!); - const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch); - // new query || at start, go to end - if (prevMatchedIndex === -1 || prevMatchedIndex === 0) { - setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]); + if (spanFilterMatches && (currentSpanIndex === -1 || currentSpanIndex === 0)) { + setCurrentSpanIndex(spanFilterMatches.size - 1); return; } // get prev - setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]); + setCurrentSpanIndex(currentSpanIndex - 1); }; + const buttonEnabled = spanFilterMatches && spanFilterMatches?.size > 0; const resetEnabled = useMemo(() => { return ( (search.serviceName && search.serviceName !== '') || @@ -102,7 +105,26 @@ export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProp }) ); }, [search.serviceName, search.spanName, search.from, search.to, search.tags]); - const buttonEnabled = spanFilterMatches && spanFilterMatches?.size > 0; + + const amountText = spanFilterMatches?.size === 1 ? 'match' : 'matches'; + const matches = + spanFilterMatches?.size === 0 ? ( + <> + 0 matches + + + + + + + ) : currentSpanIndex !== -1 ? ( + `${currentSpanIndex + 1}/${spanFilterMatches?.size} ${amountText}` + ) : ( + `${spanFilterMatches?.size} ${amountText}` + ); return (
@@ -129,6 +151,7 @@ export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProp
+ {spanFilterMatches ? matches : `${totalSpans} spans`}
); } diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx index 7c8abc3ae80..b13f7105c18 100644 --- a/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx @@ -2,10 +2,11 @@ import { css } from '@emotion/css'; import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { Button } from '@grafana/ui'; +import { Button, useTheme2 } from '@grafana/ui'; import { ExemplarTraceIdDestination } from '../types'; +import { overhaulStyles } from './ConfigEditor'; import ExemplarSetting from './ExemplarSetting'; type Props = { @@ -15,9 +16,11 @@ type Props = { }; export function ExemplarsSettings({ options, onChange, disabled }: Props) { + const theme = useTheme2(); + const styles = overhaulStyles(theme); return ( - <> -

Exemplars

+
+
Exemplars
{options && options.map((option, index) => { @@ -57,8 +60,7 @@ export function ExemplarsSettings({ options, onChange, disabled }: Props) { Add )} - {disabled && !options && No exemplars configurations} - +
); } diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx index bf91f3d5be8..aea4755989e 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx @@ -3,11 +3,10 @@ import React, { SyntheticEvent } from 'react'; import { Provider } from 'react-redux'; import { SelectableValue } from '@grafana/data'; -import { EventsWithValidation } from '@grafana/ui'; import { configureStore } from '../../../../store/configureStore'; -import { getValueFromEventItem, promSettingsValidationEvents, PromSettings } from './PromSettings'; +import { getValueFromEventItem, PromSettings } from './PromSettings'; import { createDefaultConfigOptions } from './mocks'; describe('PromSettings', () => { @@ -38,58 +37,6 @@ describe('PromSettings', () => { }); }); - describe('promSettingsValidationEvents', () => { - const validationEvents = promSettingsValidationEvents; - - it('should have one event handlers', () => { - expect(Object.keys(validationEvents).length).toEqual(1); - }); - - it('should have an onBlur handler', () => { - expect(validationEvents.hasOwnProperty(EventsWithValidation.onBlur)).toBe(true); - }); - - it('should have one rule', () => { - expect(validationEvents[EventsWithValidation.onBlur].length).toEqual(1); - }); - - describe('when calling the rule with an empty string', () => { - it('then it should return true', () => { - expect(validationEvents[EventsWithValidation.onBlur][0].rule('')).toBe(true); - }); - }); - - it.each` - value | expected - ${'1ms'} | ${true} - ${'1M'} | ${true} - ${'1w'} | ${true} - ${'1d'} | ${true} - ${'1h'} | ${true} - ${'1m'} | ${true} - ${'1s'} | ${true} - ${'1y'} | ${true} - `( - "when calling the rule with correct formatted value: '$value' then result should be '$expected'", - ({ value, expected }) => { - expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); - } - ); - - it.each` - value | expected - ${'1 ms'} | ${false} - ${'1x'} | ${false} - ${' '} | ${false} - ${'w'} | ${false} - ${'1.0s'} | ${false} - `( - "when calling the rule with incorrect formatted value: '$value' then result should be '$expected'", - ({ value, expected }) => { - expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); - } - ); - }); describe('PromSettings component', () => { const defaultProps = createDefaultConfigOptions(); diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx index eeabf4a8591..07fb205f58f 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -1,24 +1,15 @@ -import React, { SyntheticEvent } from 'react'; +import React, { SyntheticEvent, useState } from 'react'; import semver from 'semver/preload'; import { DataSourcePluginOptionsEditorProps, DataSourceSettings as DataSourceSettingsType, - isValidDuration, onUpdateDatasourceJsonDataOptionChecked, SelectableValue, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime/src'; -import { - EventsWithValidation, - InlineField, - InlineFormLabel, - InlineSwitch, - LegacyForms, - regexValidation, - Select, -} from '@grafana/ui'; +import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui'; import config from '../../../../core/config'; import { useUpdateDatasource } from '../../../../features/datasources/state'; @@ -27,11 +18,10 @@ import { QueryEditorMode } from '../querybuilder/shared/types'; import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache'; import { PrometheusCacheLevel, PromOptions } from '../types'; +import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH, validateInput } from './ConfigEditor'; import { ExemplarsSettings } from './ExemplarsSettings'; import { PromFlavorVersions } from './PromFlavorVersions'; -const { Input, FormField } = LegacyForms; - const httpOptions = [ { value: 'POST', label: 'POST' }, { value: 'GET', label: 'GET' }, @@ -60,6 +50,13 @@ const prometheusFlavorSelectItems: PrometheusSelectItemsType = [ type Props = Pick, 'options' | 'onOptionsChange'>; +// single duration input +export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/; + +// multiple duration input +export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/; + +const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s'; /** * Returns the closest version to what the user provided that we have in our PromFlavorVersions for the currently selected flavor * Bugs: It will only reject versions that are a major release apart, so Mimir 2.x might get selected for Prometheus 2.8 if the user selects an incorrect flavor @@ -158,76 +155,132 @@ export const PromSettings = (props: Props) => { options.jsonData.httpMethod = 'POST'; } + const theme = useTheme2(); + const styles = overhaulStyles(theme); + + type ValidDuration = { + timeInterval: string; + queryTimeout: string; + incrementalQueryOverlapWindow: string; + }; + + const [validDuration, updateValidDuration] = useState({ + timeInterval: '', + queryTimeout: '', + incrementalQueryOverlapWindow: '', + }); + return ( <> +
Interval behaviour
{/* Scrape interval */}
- + This interval is how frequently Prometheus scrapes targets. Set this to the typical scrape and + evaluation interval configured in your Prometheus config file. If you set this to a greater value than + your Prometheus config file interval, Grafana will evaluate the data according to this interval and + you will see less data points. Defaults to 15s. {docsTip()} + + } + interactive={true} + disabled={options.readOnly} + > + <> updateValidDuration({ ...validDuration, timeInterval: e.currentTarget.value })} /> - } - tooltip="Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s." - /> + {validateInput(validDuration.timeInterval, DURATION_REGEX, durationError)} + +
{/* Query Timeout */}
- Set the Prometheus query timeout. {docsTip()}} + interactive={true} + disabled={options.readOnly} + > + <> updateValidDuration({ ...validDuration, queryTimeout: e.currentTarget.value })} /> - } - tooltip="Set the Prometheus query timeout." - /> + {validateInput(validDuration.queryTimeout, DURATION_REGEX, durationError)} + +
- {/* HTTP Method */} -
- - HTTP method - - o.value === options.jsonData.defaultEditor) ?? + editorOptions.find((o) => o.value === QueryEditorMode.Builder) + } + onChange={onChangeHandler('defaultEditor', options, onOptionsChange)} + width={40} + /> + +
+
+ + Checking this option will disable the metrics chooser and metric/label support in the query field's + autocomplete. This helps if you have performance issues with bigger Prometheus instances. {docsTip()} + + } + interactive={true} + disabled={options.readOnly} + className={styles.switchField} + > + + +
+
+ +
Performance
{!options.jsonData.prometheusType && !options.jsonData.prometheusVersion && options.readOnly && ( -
+
For more information on configuring prometheus type and version in data sources, see the{' '} provisioning documentation @@ -236,182 +289,212 @@ export const PromSettings = (props: Props) => {
)}
-
+
- o.value === options.jsonData.prometheusType)} - onChange={onChangeHandler( - 'prometheusType', - { + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Set this to the type of your prometheus database, e.g. Prometheus, Cortex, Mimir or Thanos. Changing + this field will save your current settings, and attempt to detect the version. Certain types of + Prometheus support or do not support various APIs. For example, some types support regex matching for + label queries to improve performance. Some types have an API for metadata. If you set this incorrectly + you may experience odd behavior when querying metrics and labels. Please check your Prometheus + documentation to ensure you enter the correct type. {docsTip()} + + } + interactive={true} + disabled={options.readOnly} + > + o.value === options.jsonData.prometheusVersion + )} + onChange={onChangeHandler('prometheusVersion', options, onOptionsChange)} + width={40} /> - } - /> -
+ +
+ )}
{config.featureToggles.prometheusResourceBrowserCache && (
- o.value === options.jsonData.cacheLevel) ?? PrometheusCacheLevel.Low - } - /> + labelWidth={PROM_CONFIG_LABEL_WIDTH} + tooltip={ + <> + Sets the browser caching level for editor queries. Higher cache settings are recommended for high + cardinality data sources. + } - /> + interactive={true} + disabled={options.readOnly} + > + isValidDuration(value), - errorMessage: 'Invalid duration. Example values: 100s, 10m', - }, - ], - }} + onBlur={(e) => + updateValidDuration({ ...validDuration, incrementalQueryOverlapWindow: e.currentTarget.value }) + } className="width-25" value={options.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow} onChange={onChangeHandler('incrementalQueryOverlapWindow', options, onOptionsChange)} spellCheck={false} - disabled={options.readOnly} /> - } - /> + {validateInput(validDuration.incrementalQueryOverlapWindow, MULTIPLE_DURATION_REGEX, durationError)} + + )}
+ +
Other
+
+
+
+ + Add custom parameters to the Prometheus query URL. For example timeout, partial_response, dedup, or + max_source_resolution. Multiple parameters should be concatenated together with an ‘&’. {docsTip()} + + } + interactive={true} + disabled={options.readOnly} + > + + +
+
+
+ {/* HTTP Method */} +
+ + You can use either POST or GET HTTP method to query your Prometheus data source. POST is the + recommended method as it allows bigger queries. Change this to GET if you have a Prometheus version + older than 2.1 or if POST requests are restricted in your network. {docsTip()} + + } + interactive={true} + label="HTTP method" + disabled={options.readOnly} + > + { onChange({ ...value, @@ -183,7 +184,7 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps { onChange({ ...value, From d1229b532d22beb1c4853fe02f2616a3286bb71f Mon Sep 17 00:00:00 2001 From: Selene Date: Wed, 10 May 2023 01:13:45 +0200 Subject: [PATCH 729/729] Chore: Delete codegen dead code (#68072) * Delete codegen dead code * Use codejen * Fix lint * Use fs verify --- devenv/jsonnet/dev-dashboards.go | 21 ++- go.mod | 5 +- go.sum | 65 ++----- pkg/codegen/coremodel.go | 288 ------------------------------- pkg/codegen/diffwrite.go | 134 -------------- pkg/codegen/tmpl.go | 21 --- 6 files changed, 35 insertions(+), 499 deletions(-) delete mode 100644 pkg/codegen/coremodel.go delete mode 100644 pkg/codegen/diffwrite.go diff --git a/devenv/jsonnet/dev-dashboards.go b/devenv/jsonnet/dev-dashboards.go index b1d926a25ff..92079311756 100644 --- a/devenv/jsonnet/dev-dashboards.go +++ b/devenv/jsonnet/dev-dashboards.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "context" "embed" "fmt" "os" @@ -13,8 +14,8 @@ import ( "strings" "text/template" + "github.com/grafana/codejen" dev_dashboards "github.com/grafana/grafana/devenv/dev-dashboards" - "github.com/grafana/grafana/pkg/codegen" ) var ( @@ -35,17 +36,21 @@ func main() { if err != nil { panic(err) } - wd := codegen.NewWriteDiffer() - wd[OUTPUT_PATH] = []byte(out) + + f := codejen.NewFile(OUTPUT_PATH, []byte(out), dummyJenny{}) + fs := codejen.NewFS() + if err = fs.Add(*f); err != nil { + panic(err) + } if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { - err = wd.Verify() + err = fs.Verify(context.Background(), "") if err != nil { fmt.Fprintf(os.Stderr, "generated code is not up to date:\n%s\nrun `make gen-jsonnet` to regenerate\n\n", err) os.Exit(1) } } else { - err = wd.Write() + err = fs.Verify(context.Background(), "") if err != nil { fmt.Fprintf(os.Stderr, "error while writing generated code to disk:\n%s\n", err) os.Exit(1) @@ -121,3 +126,9 @@ func (g *libjsonnetGen) readDir(dir string) error { } return nil } + +type dummyJenny struct{} + +func (dummyJenny) JennyName() string { + return "dummyJenny" +} diff --git a/go.mod b/go.mod index 782a4fdb6c8..beb2c46400f 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,6 @@ replace github.com/docker/docker => github.com/moby/moby v23.0.4+incompatible // contains openapi encoder fixes. remove ASAP replace cuelang.org/go => github.com/sdboyer/cue v0.5.0-beta.2.0.20230419165817-251c3ae823d8 -// contains go generation fixes -replace github.com/deepmap/oapi-codegen => github.com/spinillos/oapi-codegen v1.12.5-0.20230417081915-2945b61c0b1c - // For some insane reason, client-go seems to have a broken v12.0.0 tag on it that forces us to // hoist a replace statement. replace k8s.io/client-go => k8s.io/client-go v0.25.3 @@ -143,7 +140,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cockroachdb/apd/v2 v2.0.2 // indirect - github.com/deepmap/oapi-codegen v1.12.4 + github.com/deepmap/oapi-codegen v1.12.4 // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/go.sum b/go.sum index e607e3eedff..a3d557fd4c0 100644 --- a/go.sum +++ b/go.sum @@ -458,8 +458,6 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/buildkite/yaml v2.1.0+incompatible h1:xirI+ql5GzfikVNDmt+yeiXpf/v1Gt03qXTtT5WXdr8= github.com/buildkite/yaml v2.1.0+incompatible/go.mod h1:UoU8vbcwu1+vjZq01+KrpSeLBgQQIjL/H7Y6KwikUrI= -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= github.com/cactus/go-statsd-client/statsd v0.0.0-20191106001114-12b4e2b38748/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI= github.com/caio/go-tdigest v3.1.0+incompatible h1:uoVMJ3Q5lXmVLCCqaMGHLBWnbGoN6Lpu7OAUPR60cds= @@ -485,8 +483,6 @@ github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOo github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 h1:XCdvHbz3LhewBHN7+mQPx0sg/Hxil/1USnBmxkjHcmY= github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= @@ -631,6 +627,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= github.com/crossdock/crossdock-go v0.0.0-20160816171116-049aabb0122b/go.mod h1:v9FBN7gdVTpiD/+LZ7Po0UKvROyT87uLVxTHVky/dlQ= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -657,9 +654,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= +github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s= +github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= @@ -798,6 +796,8 @@ github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 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.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/getkin/kin-openapi v0.115.0 h1:c8WHRLVY3G8m9jQTy0/DnIuljgRwTCB5twZytQS4JyU= github.com/getkin/kin-openapi v0.115.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= @@ -809,7 +809,6 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= -github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= @@ -819,7 +818,7 @@ github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1T github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= @@ -993,16 +992,12 @@ github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUri github.com/go-openapi/validate v0.22.1 h1:G+c2ub6q47kfX1sOBLwIQwzBVt8qmOAARyo/9Fqs9NU= github.com/go-openapi/validate v0.22.1/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 h1:JVrqSeQfdhYRFk24TvhTZWU0q8lfCojxZQFi3Ou7+uY= @@ -1054,8 +1049,6 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= github.com/gocql/gocql v0.0.0-20200228163523-cd4b606dd2fb/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= @@ -1084,7 +1077,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -1288,8 +1280,6 @@ github.com/grafana/go-mssqldb v0.9.1 h1:3CqteWF0CadwXV9f3FxoI+i3uSW3azjTlQipyOJt github.com/grafana/go-mssqldb v0.9.1/go.mod h1:HTCsUqZdb7oIO7jc37YauiSB5C3P/13AnpctVWBhlus= github.com/grafana/go-mssqldb v0.9.2 h1:FkyRJR4ywsT07iMtpFMHStrl8uuNkGIwp253Fee06z8= github.com/grafana/go-mssqldb v0.9.2/go.mod h1:HTCsUqZdb7oIO7jc37YauiSB5C3P/13AnpctVWBhlus= -github.com/grafana/grafana-aws-sdk v0.12.0 h1:eUjFdFZeZE+nyu/RMRz+qFxTBew69ToLBrbRhTbjkfM= -github.com/grafana/grafana-aws-sdk v0.12.0/go.mod h1:rCXLYoMpPqF90U7XqgVJ1HIAopFVF0bB3SXBVEJIm3I= github.com/grafana/grafana-aws-sdk v0.15.0 h1:ZOPHQcC5NUFi1bLTwnju91G0KmGh1z+qXOKj9nDfxNs= github.com/grafana/grafana-aws-sdk v0.15.0/go.mod h1:rCXLYoMpPqF90U7XqgVJ1HIAopFVF0bB3SXBVEJIm3I= github.com/grafana/grafana-azure-sdk-go v1.6.0 h1:lxvH/mVY7gKBtJKhZ4B/6tIZFY7Jth97HxBA38olaxs= @@ -1603,7 +1593,6 @@ github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0= github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/knadh/koanf v1.2.0/go.mod h1:xpPTwMhsA/aaQLAilyCCqfpEiY1gpa160AiCuWHJUjY= @@ -1630,22 +1619,17 @@ github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLg github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353 h1:X/79QL0b4YJVO5+OsPH9rF2u428CIrGL/jLmPsoOQQ4= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= -github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= -github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= -github.com/lestrrat-go/jwx v1.2.25/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= -github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= @@ -1685,15 +1669,17 @@ github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHef github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matryer/moq v0.3.1/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -1714,7 +1700,6 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -1941,7 +1926,6 @@ github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrap github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= -github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= @@ -2197,8 +2181,6 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= -github.com/spinillos/oapi-codegen v1.12.5-0.20230417081915-2945b61c0b1c h1:u9EyGmVLczUaCNBvY4j9NItCJNaQRbEWGUR8fcu20Kk= -github.com/spinillos/oapi-codegen v1.12.5-0.20230417081915-2945b61c0b1c/go.mod h1:WMhniMLAXaHHNYz+Nk+h35qYieaeUaASStrkdkHnBr8= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= @@ -2254,7 +2236,6 @@ github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZF github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f h1:A+MmlgpvrHLeUP8dkBVn4Pnf5Bp5Yk2OALm7SEJLLE8= github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= github.com/uber-go/tally v3.3.15+incompatible/go.mod h1:YDTIBxdXyOU/sCWilKB4bgyufu1cEi0jdVnRdxvjnmU= @@ -2272,9 +2253,8 @@ github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= -github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= -github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= @@ -2291,6 +2271,7 @@ github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU= github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= @@ -2500,7 +2481,6 @@ go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk= gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180505025534-4ec37c66abab/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -2535,6 +2515,7 @@ golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -2556,12 +2537,9 @@ golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2614,7 +2592,6 @@ 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/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220818022119-ed83ed61efb9/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -2714,8 +2691,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220907135653-1e95f45603a7/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= @@ -2854,6 +2829,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2937,9 +2913,7 @@ golang.org/x/sys v0.0.0-20220908150016-7ac13a9a928d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= @@ -2949,8 +2923,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= @@ -2967,7 +2939,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= @@ -2977,6 +2948,7 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -3076,7 +3048,6 @@ golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyj golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= diff --git a/pkg/codegen/coremodel.go b/pkg/codegen/coremodel.go deleted file mode 100644 index d6c5dc5aee2..00000000000 --- a/pkg/codegen/coremodel.go +++ /dev/null @@ -1,288 +0,0 @@ -package codegen - -import ( - "bytes" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing/fstest" - - cerrors "cuelang.org/go/cue/errors" - "cuelang.org/go/pkg/encoding/yaml" - "github.com/deepmap/oapi-codegen/pkg/codegen" - "github.com/getkin/kin-openapi/openapi3" - "github.com/grafana/cuetsy" - tsast "github.com/grafana/cuetsy/ts/ast" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/thema" - "github.com/grafana/thema/encoding/openapi" -) - -// CoremodelDeclaration contains the results of statically analyzing a Grafana -// directory for a Thema lineage. -type CoremodelDeclaration struct { - Lineage thema.Lineage - // Absolute path to the coremodel's coremodel.cue file. - LineagePath string - // Path to the coremodel's coremodel.cue file relative to repo root. - RelativePath string - // Indicates whether the coremodel is considered canonical or not. Generated - // code from not-yet-canonical coremodels should include appropriate caveats in - // documentation and possibly be hidden from external public API surface areas. - IsCanonical bool - - // Indicates whether the coremodel represents an API type, and should therefore - // be included in API client code generation. - IsAPIType bool -} - -// ExtractLineage loads a Grafana Thema lineage from the filesystem. -// -// The provided path must be the absolute path to the file containing the -// lineage to be loaded. -// -// This loading approach is intended primarily for use with code generators, or -// other use cases external to grafana-server backend. For code within -// grafana-server, prefer lineage loaders provided in e.g. pkg/coremodel/*. -func ExtractLineage(path string, rt *thema.Runtime) (*CoremodelDeclaration, error) { - if !filepath.IsAbs(path) { - return nil, fmt.Errorf("must provide an absolute path, got %q", path) - } - - ec := &CoremodelDeclaration{ - LineagePath: path, - } - - var find func(path string) (string, error) - find = func(path string) (string, error) { - parent := filepath.Dir(path) - if parent == path { - return "", errors.New("grafana root directory could not be found") - } - fp := filepath.Join(path, "go.mod") - if _, err := os.Stat(fp); err == nil { - return path, nil - } - return find(parent) - } - groot, err := find(path) - if err != nil { - return ec, err - } - - f, err := os.Open(ec.LineagePath) - if err != nil { - return nil, fmt.Errorf("could not open lineage file at %s: %w", path, err) - } - - byt, err := io.ReadAll(f) - if err != nil { - return nil, err - } - - fs := fstest.MapFS{ - "coremodel.cue": &fstest.MapFile{ - Data: byt, - }, - } - - // ec.RelativePath, err = filepath.Rel(groot, filepath.Dir(path)) - ec.RelativePath, err = filepath.Rel(groot, path) - if err != nil { - // should be unreachable, since we rootclimbed to find groot above - panic(err) - } - ec.RelativePath = filepath.ToSlash(ec.RelativePath) - ec.Lineage, err = cuectx.LoadGrafanaInstancesWithThema(filepath.Dir(ec.RelativePath), fs, rt) - if err != nil { - return ec, err - } - ec.IsCanonical = isCanonical(ec.Lineage.Name()) - ec.IsAPIType = isAPIType(ec.Lineage.Name()) - return ec, nil -} - -// toTemplateObj extracts creates a struct with all the useful strings for template generation. -func (cd *CoremodelDeclaration) toTemplateObj() tplVars { - lin := cd.Lineage - sch := thema.SchemaP(lin, thema.LatestVersion(lin)) - - return tplVars{ - Name: lin.Name(), - LineagePath: cd.RelativePath, - PkgPath: filepath.ToSlash(filepath.Join("github.com/grafana/grafana", filepath.Dir(cd.RelativePath))), - TitleName: strings.Title(lin.Name()), // nolint - LatestSeqv: sch.Version()[0], - LatestSchv: sch.Version()[1], - } -} - -func isCanonical(name string) bool { - return canonicalCoremodels[name] -} - -func isAPIType(name string) bool { - return !nonAPITypes[name] -} - -// FIXME specifying coremodel canonicality DOES NOT belong here - it should be part of the coremodel declaration. -var canonicalCoremodels = map[string]bool{ - "dashboard": false, -} - -// FIXME this also needs to be moved into coremodel metadata -var nonAPITypes = map[string]bool{ - "pluginmeta": true, -} - -// PathVersion returns the string path element to use for the latest schema. -// "x" if not yet canonical, otherwise, "v" -func (cd *CoremodelDeclaration) PathVersion() string { - if !cd.IsCanonical { - return "x" - } - return fmt.Sprintf("v%v", thema.LatestVersion(cd.Lineage)[0]) -} - -// GenerateGoCoremodel generates a standard Go model struct and coremodel -// implementation from a coremodel CUE declaration. -// -// The provided path must be a directory. Generated code files will be written -// to that path. The final element of the path must match the Lineage.Name(). -func (cd *CoremodelDeclaration) GenerateGoCoremodel(path string) (WriteDiffer, error) { - lin, rt := cd.Lineage, cd.Lineage.Runtime() - _, name := filepath.Split(path) - if name != lin.Name() { - return nil, fmt.Errorf("lineage name %q must match final element of path, got %q", lin.Name(), path) - } - - sch := thema.SchemaP(lin, thema.LatestVersion(lin)) - f, err := openapi.GenerateSchema(sch, nil) - if err != nil { - return nil, fmt.Errorf("thema openapi generation failed: %w", err) - } - - str, err := yaml.Marshal(rt.Context().BuildFile(f)) - if err != nil { - return nil, fmt.Errorf("cue-yaml marshaling failed: %w", err) - } - - loader := openapi3.NewLoader() - oT, err := loader.LoadFromData([]byte(str)) - if err != nil { - return nil, fmt.Errorf("loading generated openapi failed; %w", err) - } - - var importbuf bytes.Buffer - if err = tmpls.Lookup("coremodel_imports.tmpl").Execute(&importbuf, tvars_coremodel_imports{ - PackageName: lin.Name(), - }); err != nil { - return nil, fmt.Errorf("error executing imports template: %w", err) - } - - gostr, err := codegen.Generate(oT, codegen.Configuration{ - PackageName: lin.Name(), - Generate: codegen.GenerateOptions{ - Models: true, - }, - Compatibility: codegen.CompatibilityOptions{ - AlwaysPrefixEnumValues: true, - }, - OutputOptions: codegen.OutputOptions{ - SkipFmt: true, - SkipPrune: true, - UserTemplates: map[string]string{ - "imports.tmpl": importbuf.String(), - "typedef.tmpl": tmplTypedef, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("openapi generation failed: %w", err) - } - - buf := new(bytes.Buffer) - if err = tmpls.Lookup("autogen_header.tmpl").Execute(buf, tvars_autogen_header{ - LineagePath: cd.RelativePath, - GeneratorPath: "pkg/framework/coremodel/gen.go", // FIXME hardcoding is not OK - }); err != nil { - return nil, fmt.Errorf("error executing header template: %w", err) - } - - fmt.Fprint(buf, "\n", gostr) - - vars := cd.toTemplateObj() - err = tmpls.Lookup("addenda.tmpl").Execute(buf, vars) - if err != nil { - panic(err) - } - - fullp := filepath.Join(path, fmt.Sprintf("%s_gen.go", lin.Name())) - byt, err := postprocessGoFile(genGoFile{ - path: fullp, - walker: PrefixDropper(strings.Title(lin.Name())), - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - - wd := NewWriteDiffer() - wd[fullp] = byt - - return wd, nil -} - -type tplVars struct { - Name string - LineagePath, PkgPath string - TitleName string - LatestSeqv, LatestSchv uint - IsComposed bool -} - -func (cd *CoremodelDeclaration) GenerateTypescriptCoremodel() (*tsast.File, error) { - schv := cd.Lineage.Latest().Underlying() - - tf, err := cuetsy.GenerateAST(schv, cuetsy.Config{ - Export: true, - }) - if err != nil { - return nil, fmt.Errorf("cuetsy tf gen failed: %w", err) - } - - top, err := cuetsy.GenerateSingleAST(strings.Title(cd.Lineage.Name()), schv, cuetsy.TypeInterface) - if err != nil { - return nil, fmt.Errorf("cuetsy top gen failed: %s", cerrors.Details(err, nil)) - } - - buf := new(bytes.Buffer) - if err := tmpls.Lookup("autogen_header.tmpl").Execute(buf, tvars_autogen_header{ - LineagePath: cd.RelativePath, - GeneratorPath: "pkg/framework/coremodel/gen.go", // FIXME hardcoding is not OK - }); err != nil { - return nil, fmt.Errorf("error executing header template: %w", err) - } - tf.Doc = &tsast.Comment{ - Text: buf.String(), - } - - // TODO until cuetsy can toposort its outputs, put the top/parent type at the bottom of the file. - tf.Nodes = append(tf.Nodes, top.T) - if top.D != nil { - tf.Nodes = append(tf.Nodes, top.D) - } - return tf, nil -} - -var tmplTypedef = `{{range .Types}} -{{ with .Schema.Description }}{{ . }}{{ else }}// {{.TypeName}} is the Go representation of a {{.JsonName}}.{{ end }} -// -// 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 {{.TypeName}} {{if and (opts.AliasTypes) (.CanAlias)}}={{end}} {{.Schema.TypeDecl}} -{{end}} -` diff --git a/pkg/codegen/diffwrite.go b/pkg/codegen/diffwrite.go deleted file mode 100644 index fb1cece0fcd..00000000000 --- a/pkg/codegen/diffwrite.go +++ /dev/null @@ -1,134 +0,0 @@ -package codegen - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/go-multierror" - "golang.org/x/sync/errgroup" -) - -// WriteDiffer is a pseudo-filesystem that supports batch-writing its contents -// to the real filesystem, or batch-comparing its contents to the real -// filesystem. Its intended use is for idiomatic `go generate`-style code -// generators, where it is expected that the results of codegen are committed to -// version control. -// -// In such cases, the normal behavior of a generator is to write files to disk, -// but in CI, that behavior should change to verify that what is already on disk -// is identical to the results of code generation. This allows CI to ensure that -// the results of code generation are always up to date. WriteDiffer supports -// these related behaviors through its Write() and Verify() methods, respectively. -// -// Note that the statelessness of WriteDiffer means that, if a particular input -// to the code generator goes away, it will not notice generated files left -// behind if their inputs are removed. -// TODO introduce a search/match system -type WriteDiffer map[string][]byte - -func NewWriteDiffer() WriteDiffer { - return WriteDiffer(make(map[string][]byte)) -} - -type writeSlice []struct { - path string - contents []byte -} - -// Verify checks the contents of each file against the filesystem. It emits an error -// if any of its contained files differ. -func (wd WriteDiffer) Verify() error { - var result error - - for _, item := range wd.toSlice() { - if _, err := os.Stat(item.path); err != nil { - if errors.Is(err, os.ErrNotExist) { - result = multierror.Append(result, fmt.Errorf("%s: generated file should exist, but does not", item.path)) - } else { - result = multierror.Append(result, fmt.Errorf("%s: could not stat generated file: %w", item.path, err)) - } - continue - } - - f, err := os.Open(filepath.Clean(item.path)) - if err != nil { - result = multierror.Append(result, fmt.Errorf("%s: %w", item.path, err)) - continue - } - - ob, err := io.ReadAll(f) - if err != nil { - result = multierror.Append(result, fmt.Errorf("%s: %w", item.path, err)) - continue - } - dstr := cmp.Diff(string(ob), string(item.contents)) - if dstr != "" { - result = multierror.Append(result, fmt.Errorf("%s would have changed:\n\n%s", item.path, dstr)) - } - } - - return result -} - -// Write writes all of the files to their indicated paths. -func (wd WriteDiffer) Write() error { - g, _ := errgroup.WithContext(context.TODO()) - g.SetLimit(12) - - for _, item := range wd.toSlice() { - it := item - g.Go(func() error { - err := os.MkdirAll(filepath.Dir(it.path), os.ModePerm) - if err != nil { - return fmt.Errorf("%s: failed to ensure parent directory exists: %w", it.path, err) - } - - if err := os.WriteFile(it.path, it.contents, 0644); err != nil { - return fmt.Errorf("%s: error while writing file: %w", it.path, err) - } - return nil - }) - } - - return g.Wait() -} - -func (wd WriteDiffer) toSlice() writeSlice { - sl := make(writeSlice, 0, len(wd)) - type ws struct { - path string - contents []byte - } - - for k, v := range wd { - sl = append(sl, ws{ - path: k, - contents: v, - }) - } - - sort.Slice(sl, func(i, j int) bool { - return sl[i].path < sl[j].path - }) - - return sl -} - -// Merge combines all the entries from the provided WriteDiffer into the callee -// WriteDiffer. Duplicate paths result in an error. -func (wd WriteDiffer) Merge(wd2 WriteDiffer) error { - for k, v := range wd2 { - if _, has := wd[k]; has { - return fmt.Errorf("path %s already exists in write differ", k) - } - wd[k] = v - } - - return nil -} diff --git a/pkg/codegen/tmpl.go b/pkg/codegen/tmpl.go index e5071707c44..beaca5c2318 100644 --- a/pkg/codegen/tmpl.go +++ b/pkg/codegen/tmpl.go @@ -1,7 +1,6 @@ package codegen import ( - "bytes" "embed" "strings" "text/template" @@ -28,12 +27,6 @@ var tmplFS embed.FS // The following group of types, beginning with tvars_*, all contain the set // of variables expected by the corresponding named template file under tmpl/ type ( - tvars_autogen_header struct { - GeneratorPath string - LineagePath string - LineageCUEPath string - GenLicense bool - } tvars_gen_header struct { MainGenerator string Using []codejen.NamedJenny @@ -45,23 +38,9 @@ type ( KindPackagePrefix string Kinds []kindsys.Core } - tvars_coremodel_imports struct { - PackageName string - } tvars_resource struct { PackageName string KindName string SubresourceNames []string } ) - -type HeaderVars = tvars_autogen_header - -// GenGrafanaHeader creates standard header elements for generated Grafana files. -func GenGrafanaHeader(vars HeaderVars) string { - buf := new(bytes.Buffer) - if err := tmpls.Lookup("autogen_header.tmpl").Execute(buf, vars); err != nil { - panic(err) - } - return buf.String() -}