diff --git a/.betterer.results b/.betterer.results index b09b00781de..fd31bfb5837 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1538,7 +1538,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "packages/grafana-ui/src/components/Table/utils.test.ts:5381": [ + "packages/grafana-ui/src/components/Table/utils.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], @@ -1548,19 +1548,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "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"] + [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], - "packages/grafana-ui/src/components/Table/utils.ts:5381": [ + "packages/grafana-ui/src/components/Table/utils.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], @@ -3995,12 +3985,6 @@ exports[`better eslint`] = { "public/app/features/folders/state/actions.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/geo/editor/GazetteerPathEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], "public/app/features/geo/format/geohash.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4554,6 +4538,9 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/scenes/components/layout/SceneFlexLayout.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -4651,8 +4638,7 @@ exports[`better eslint`] = { "public/app/features/search/state/SearchStateManager.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "2"] ], "public/app/features/search/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6349,8 +6335,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6649,16 +6634,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/prometheus/querybuilder/shared/OperationEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/datasource/prometheus/querybuilder/shared/OperationHeader.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/prometheus/querybuilder/shared/OperationList.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -6670,22 +6647,13 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/datasource/prometheus/querybuilder/shared/types.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"] ], - "public/app/plugins/datasource/prometheus/querybuilder/state.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/datasource/prometheus/querybuilder/state.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/datasource/prometheus/result_transformer.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -6721,8 +6689,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "31"] ], "public/app/plugins/datasource/prometheus/result_transformer.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/prometheus/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -6732,8 +6699,7 @@ exports[`better eslint`] = { ], "public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6767,16 +6733,15 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "8"], [0, 0, 0, "Do not use any type assertions.", "9"], [0, 0, 0, "Do not use any type assertions.", "10"], [0, 0, 0, "Do not use any type assertions.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Do not use any type assertions.", "13"], + [0, 0, 0, "Unexpected any. Specify a different type.", "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, "Unexpected any. Specify a different type.", "17"] + [0, 0, 0, "Unexpected any. Specify a different type.", "16"] ], "public/app/plugins/datasource/tempo/language_provider.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6810,33 +6775,7 @@ exports[`better eslint`] = { ], "public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "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"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"] - ], - "public/app/plugins/datasource/tempo/traceql/autocomplete.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/datasource/testdata/ConfigEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6850,13 +6789,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Do not use any type assertions.", "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, "Unexpected any. Specify a different type.", "8"] ], "public/app/plugins/datasource/testdata/components/PredictablePulseEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -6876,13 +6812,11 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], "public/app/plugins/datasource/testdata/components/SimulationSchemaForm.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/datasource/testdata/components/StreamingClientEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -6998,18 +6932,14 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/barchart/bars.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, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"] + [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"] ], "public/app/plugins/panel/barchart/module.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -7018,29 +6948,10 @@ exports[`better eslint`] = { "public/app/plugins/panel/barchart/quadtree.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/panel/barchart/suggestions.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "public/app/plugins/panel/barchart/utils.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] - ], "public/app/plugins/panel/barchart/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/plugins/panel/bargauge/BarGaugeMigrations.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/plugins/panel/bargauge/BarGaugePanel.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/candlestick/CandlestickPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -7067,9 +6978,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "10"], [0, 0, 0, "Do not use any type assertions.", "11"] ], - "public/app/plugins/panel/canvas/CanvasContextMenu.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/canvas/CanvasPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -7113,8 +7021,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/plugins/panel/canvas/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/dashlist/module.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -7131,12 +7038,6 @@ exports[`better eslint`] = { "public/app/plugins/panel/debug/RenderInfoViewer.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/plugins/panel/gauge/GaugeMigrations.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/gauge/GaugeMigrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -7154,16 +7055,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "public/app/plugins/panel/geomap/editor/GeomapStyleRulesEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/panel/geomap/editor/StyleEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], @@ -7174,8 +7071,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "8"], [0, 0, 0, "Do not use any type assertions.", "9"], [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"] + [0, 0, 0, "Do not use any type assertions.", "11"] ], "public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -7201,9 +7097,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], - "public/app/plugins/panel/geomap/style/utils.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/geomap/utils/layers.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -7215,14 +7108,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/plugins/panel/geomap/view.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], - "public/app/plugins/panel/gettingstarted/GettingStarted.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/gettingstarted/components/Step.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -7568,191 +7453,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], - "public/app/plugins/panel/graph/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/plugins/panel/heatmap-old/axes_editor.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] - ], - "public/app/plugins/panel/heatmap-old/color_legend.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "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, "Do not use any type assertions.", "19"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"], - [0, 0, 0, "Unexpected any. Specify a different type.", "22"] - ], - "public/app/plugins/panel/heatmap-old/color_scale.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] - ], - "public/app/plugins/panel/heatmap-old/display_editor.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"] - ], - "public/app/plugins/panel/heatmap-old/heatmap_ctrl.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"] - ], - "public/app/plugins/panel/heatmap-old/heatmap_data_converter.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "Unexpected any. Specify a different type.", "17"] - ], - "public/app/plugins/panel/heatmap-old/heatmap_tooltip.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "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"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"], - [0, 0, 0, "Unexpected any. Specify a different type.", "22"], - [0, 0, 0, "Unexpected any. Specify a different type.", "23"], - [0, 0, 0, "Unexpected any. Specify a different type.", "24"], - [0, 0, 0, "Unexpected any. Specify a different type.", "25"], - [0, 0, 0, "Unexpected any. Specify a different type.", "26"], - [0, 0, 0, "Unexpected any. Specify a different type.", "27"], - [0, 0, 0, "Unexpected any. Specify a different type.", "28"] - ], - "public/app/plugins/panel/heatmap-old/rendering.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Unexpected any. Specify a different type.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "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"], - [0, 0, 0, "Unexpected any. Specify a different type.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"], - [0, 0, 0, "Unexpected any. Specify a different type.", "22"], - [0, 0, 0, "Unexpected any. Specify a different type.", "23"], - [0, 0, 0, "Unexpected any. Specify a different type.", "24"], - [0, 0, 0, "Unexpected any. Specify a different type.", "25"], - [0, 0, 0, "Unexpected any. Specify a different type.", "26"], - [0, 0, 0, "Unexpected any. Specify a different type.", "27"], - [0, 0, 0, "Unexpected any. Specify a different type.", "28"], - [0, 0, 0, "Unexpected any. Specify a different type.", "29"], - [0, 0, 0, "Unexpected any. Specify a different type.", "30"], - [0, 0, 0, "Unexpected any. Specify a different type.", "31"], - [0, 0, 0, "Unexpected any. Specify a different type.", "32"], - [0, 0, 0, "Unexpected any. Specify a different type.", "33"], - [0, 0, 0, "Unexpected any. Specify a different type.", "34"], - [0, 0, 0, "Unexpected any. Specify a different type.", "35"], - [0, 0, 0, "Unexpected any. Specify a different type.", "36"], - [0, 0, 0, "Unexpected any. Specify a different type.", "37"], - [0, 0, 0, "Unexpected any. Specify a different type.", "38"], - [0, 0, 0, "Unexpected any. Specify a different type.", "39"], - [0, 0, 0, "Unexpected any. Specify a different type.", "40"], - [0, 0, 0, "Unexpected any. Specify a different type.", "41"], - [0, 0, 0, "Unexpected any. Specify a different type.", "42"], - [0, 0, 0, "Unexpected any. Specify a different type.", "43"], - [0, 0, 0, "Do not use any type assertions.", "44"], - [0, 0, 0, "Unexpected any. Specify a different type.", "45"], - [0, 0, 0, "Unexpected any. Specify a different type.", "46"] - ], - "public/app/plugins/panel/heatmap-old/specs/heatmap_ctrl.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "public/app/plugins/panel/heatmap-old/specs/heatmap_data_converter.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "public/app/plugins/panel/heatmap-old/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] - ], "public/app/plugins/panel/heatmap/HeatmapHoverView.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -7847,31 +7547,19 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/nodeGraph/Edge.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/panel/nodeGraph/EdgeLabel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/nodeGraph/Legend.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], "public/app/plugins/panel/nodeGraph/Legend.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/nodeGraph/Node.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/nodeGraph/NodeGraph.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"] + [0, 0, 0, "Do not use any type assertions.", "3"] ], "public/app/plugins/panel/nodeGraph/ViewControls.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -8185,9 +7873,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "15"], [0, 0, 0, "Unexpected any. Specify a different type.", "16"] ], - "public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/.bra.toml b/.bra.toml index 005306880a0..22f56050c52 100644 --- a/.bra.toml +++ b/.bra.toml @@ -2,9 +2,8 @@ init_cmds = [ ["make", "gen-go"], ["make", "gen-jsonnet"], - ["GO_BUILD_DEV=1", "make", "build-cli"], - ["GO_BUILD_DEV=1", "make", "build-server"], - ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] + ["GO_BUILD_DEV=1", "make", "build-go"], + ["./bin/grafana", "server", "-packaging=dev", "cfg:app_mode=development"] ] watch_all = true follow_symlinks = true @@ -20,6 +19,6 @@ build_delay = 1500 cmds = [ ["make", "gen-go"], ["make", "gen-jsonnet"], - ["GO_BUILD_DEV=1", "make", "build-server"], - ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] + ["GO_BUILD_DEV=1", "make", "build-go"], + ["./bin/grafana", "server", "-packaging=dev", "cfg:app_mode=development"] ] diff --git a/.drone.star b/.drone.star index 9d2a65ebc63..2504f1a394c 100644 --- a/.drone.star +++ b/.drone.star @@ -7,7 +7,7 @@ load('scripts/drone/events/pr.star', 'pr_pipelines') load('scripts/drone/events/main.star', 'main_pipelines') load('scripts/drone/pipelines/docs.star', 'docs_pipelines') -load('scripts/drone/events/release.star', 'release_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline', 'artifacts_page_pipeline') +load('scripts/drone/events/release.star', 'oss_pipelines', 'enterprise_pipelines', 'enterprise2_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline', 'artifacts_page_pipeline') load('scripts/drone/pipelines/publish_images.star', 'publish_image_pipelines_public', 'publish_image_pipelines_security') load('scripts/drone/version.star', 'version_branch_pipelines') load('scripts/drone/events/cron.star', 'cronjobs') @@ -15,7 +15,8 @@ load('scripts/drone/vault.star', 'secrets') def main(ctx): edition = 'oss' - return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ + return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + oss_pipelines() + enterprise_pipelines() + enterprise2_pipelines() + \ + enterprise2_pipelines(prefix='custom-', trigger = {'event': ['custom']},) + \ publish_image_pipelines_public() + publish_image_pipelines_security() + \ publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ publish_npm_pipelines('public') + publish_packages_pipeline() + artifacts_page_pipeline() + \ diff --git a/.drone.yml b/.drone.yml index 103256ecf2f..afa440e9753 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,6 +2,8 @@ clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -56,6 +58,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -117,6 +121,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -253,6 +259,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -313,6 +321,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -627,6 +637,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -760,6 +772,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -830,6 +844,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -873,6 +889,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -944,6 +962,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1002,6 +1022,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1128,6 +1150,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1188,6 +1212,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1509,7 +1535,7 @@ steps: GCP_KEY: from_secret: gcp_key image: google/cloud-sdk - name: publish-images-grafana/grafana + name: publish-images-grafana volumes: - name: docker path: /var/run/docker.sock @@ -1529,7 +1555,7 @@ steps: GCP_KEY: from_secret: gcp_key image: google/cloud-sdk - name: publish-images-grafana/grafana-oss + name: publish-images-grafana-oss volumes: - name: docker path: /var/run/docker.sock @@ -1552,7 +1578,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/build upload-packages --edition oss depends_on: - end-to-end-tests-dashboards-suite - end-to-end-tests-panels-suite @@ -1569,7 +1595,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -1600,6 +1626,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1731,6 +1759,8 @@ depends_on: - main-test-backend - main-build-e2e-publish - main-integration-tests +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -1828,6 +1858,8 @@ clone: depends_on: - main-build-e2e-publish - main-integration-tests +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -2135,7 +2167,7 @@ steps: event: - tag - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -2146,7 +2178,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: upload-cdn-assets - commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/build upload-packages --edition oss depends_on: - end-to-end-tests-dashboards-suite - end-to-end-tests-panels-suite @@ -2210,6 +2242,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -2658,7 +2692,6 @@ steps: - build-backend - build-frontend - build-frontend-packages - - build-backend-enterprise2 environment: GPG_KEY_PASSWORD: from_secret: gpg_key_password @@ -2785,7 +2818,7 @@ steps: - success - failure - commands: - - ./bin/grabpl upload-cdn --edition enterprise + - ./bin/build upload-cdn --edition enterprise depends_on: - package environment: @@ -2796,7 +2829,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: upload-cdn-assets - commands: - - ./bin/grabpl upload-packages --edition enterprise + - ./bin/build upload-packages --edition enterprise depends_on: - package environment: @@ -2810,10 +2843,9 @@ steps: - ./bin/build package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} depends_on: - build-plugins - - build-backend + - build-backend-enterprise2 - build-frontend - build-frontend-packages - - build-backend-enterprise2 environment: GPG_KEY_PASSWORD: from_secret: gpg_key_password @@ -2826,7 +2858,7 @@ steps: image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - - ./bin/grabpl upload-cdn --edition enterprise2 + - ./bin/build upload-cdn --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -2837,18 +2869,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: upload-cdn-assets-enterprise2 - commands: - - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} - depends_on: - - build-frontend-packages - environment: - GCP_KEY: - from_secret: gcp_key - PRERELEASE_BUCKET: - from_secret: prerelease_bucket - image: grafana/build-container:1.6.4 - name: store-npm-packages -- commands: - - ./bin/grabpl upload-packages --edition enterprise2 + - ./bin/build upload-packages --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -2879,6 +2900,8 @@ volumes: clone: disable: true depends_on: [] +environment: + EDITION: enterprise image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3321,9 +3344,508 @@ volumes: path: /var/run/docker.sock name: docker --- +clone: + disable: true +depends_on: [] +environment: + EDITION: enterprise2 +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: release-enterprise2-build-e2e-publish +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner +- commands: + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: clone-enterprise +- commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + ${DRONE_TAG} + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + depends_on: + - clone-enterprise + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: init-enterprise +- commands: + - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd + depends_on: + - init-enterprise + environment: + CGO_ENABLED: 0 + image: golang:1.19.3 + name: compile-build-cmd +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: yarn-install +- commands: + - '# It is required that code generated from Thema/CUE be committed and in sync + with its inputs.' + - '# The following command will fail if running code generators produces any diff + in output.' + - CODEGEN_VERIFY=1 make gen-cue + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: verify-gen-cue +- commands: + - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend +- commands: + - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend-packages +- commands: + - ./bin/build build-plugins --jobs 8 --edition enterprise + depends_on: + - compile-build-cmd + - yarn-install + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: build-plugins +- commands: + - ./bin/build build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} + depends_on: + - wire-install + - compile-build-cmd + image: grafana/build-container:1.6.4 + name: build-backend-enterprise2 +- commands: + - ./bin/build package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} + depends_on: + - build-plugins + - build-backend-enterprise2 + - build-frontend + - build-frontend-packages + environment: + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: package-enterprise2 +- commands: + - ./bin/build upload-cdn --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + ENTERPRISE2_CDN_PATH: + from_secret: enterprise2-cdn-path + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-cdn-assets-enterprise2 +- commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package-enterprise2 + image: grafana/build-container:1.6.4 + name: copy-packages-for-docker +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave --ubuntu + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker fetch --edition enterprise2 + depends_on: + - build-docker-images + - build-docker-images-ubuntu + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker publish-enterprise2 --dockerhub-repo $${DOCKER_ENTERPRISE2_REPO} + depends_on: + - fetch-images-enterprise2 + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key_hg + image: google/cloud-sdk + name: publish-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build upload-packages --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-packages-enterprise2 +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- +clone: + disable: true +depends_on: [] +environment: + EDITION: enterprise2 +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: custom-release-enterprise2-build-e2e-publish +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner +- commands: + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: clone-enterprise +- commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + ${DRONE_TAG} + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + depends_on: + - clone-enterprise + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: init-enterprise +- commands: + - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd + depends_on: + - init-enterprise + environment: + CGO_ENABLED: 0 + image: golang:1.19.3 + name: compile-build-cmd +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: yarn-install +- commands: + - '# It is required that code generated from Thema/CUE be committed and in sync + with its inputs.' + - '# The following command will fail if running code generators produces any diff + in output.' + - CODEGEN_VERIFY=1 make gen-cue + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: verify-gen-cue +- commands: + - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend +- commands: + - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend-packages +- commands: + - ./bin/build build-plugins --jobs 8 --edition enterprise + depends_on: + - compile-build-cmd + - yarn-install + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: build-plugins +- commands: + - ./bin/build build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} + depends_on: + - wire-install + - compile-build-cmd + image: grafana/build-container:1.6.4 + name: build-backend-enterprise2 +- commands: + - ./bin/build package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} + depends_on: + - build-plugins + - build-backend-enterprise2 + - build-frontend + - build-frontend-packages + environment: + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: package-enterprise2 +- commands: + - ./bin/build upload-cdn --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + ENTERPRISE2_CDN_PATH: + from_secret: enterprise2-cdn-path + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-cdn-assets-enterprise2 +- commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package-enterprise2 + image: grafana/build-container:1.6.4 + name: copy-packages-for-docker +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave --ubuntu + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker fetch --edition enterprise2 + depends_on: + - build-docker-images + - build-docker-images-ubuntu + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker publish-enterprise2 --dockerhub-repo $${DOCKER_ENTERPRISE2_REPO} + depends_on: + - fetch-images-enterprise2 + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key_hg + image: google/cloud-sdk + name: publish-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build upload-packages --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-packages-enterprise2 +trigger: + event: + - custom +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3353,6 +3875,8 @@ steps: depends_on: - compile-build-cmd environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo DOCKER_PASSWORD: from_secret: docker_password DOCKER_USER: @@ -3377,13 +3901,13 @@ steps: GCP_KEY: from_secret: gcp_key image: google/cloud-sdk - name: publish-images-grafana/grafana + name: publish-images-grafana volumes: - name: docker path: /var/run/docker.sock - commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss --version-tag - ${DRONE_TAG} + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana/grafana-oss + --version-tag ${DRONE_TAG} depends_on: - fetch-images-oss environment: @@ -3412,6 +3936,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: enterprise image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3441,6 +3967,8 @@ steps: depends_on: - compile-build-cmd environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo DOCKER_PASSWORD: from_secret: docker_password DOCKER_USER: @@ -3465,7 +3993,7 @@ steps: GCP_KEY: from_secret: gcp_key image: google/cloud-sdk - name: publish-images-grafana/grafana-enterprise + name: publish-images-grafana-enterprise volumes: - name: docker path: /var/run/docker.sock @@ -3483,6 +4011,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: enterprise image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3512,6 +4042,8 @@ steps: depends_on: - compile-build-cmd environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo DOCKER_PASSWORD: from_secret: docker_password DOCKER_USER: @@ -3536,7 +4068,7 @@ steps: GCP_KEY: from_secret: gcp_key image: google/cloud-sdk - name: publish-images-grafana/grafana-enterprise + name: publish-images-grafana-enterprise volumes: - name: docker path: /var/run/docker.sock @@ -3554,6 +4086,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: all image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3596,6 +4130,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: all image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3638,6 +4174,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: all image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3699,6 +4237,8 @@ depends_on: - publish-artifacts-public - publish-docker-oss-public - publish-docker-enterprise-public +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3794,6 +4334,8 @@ depends_on: - publish-artifacts-public - publish-docker-oss-public - publish-docker-enterprise-public +environment: + EDITION: enterprise image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3886,6 +4428,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: all image_pull_secrets: - dockerconfigjson kind: pipeline @@ -4156,7 +4700,7 @@ steps: include: - packages/grafana-ui/** - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -4170,7 +4714,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/build upload-packages --edition oss depends_on: - end-to-end-tests-dashboards-suite - end-to-end-tests-panels-suite @@ -4204,6 +4748,8 @@ volumes: clone: retries: 3 depends_on: [] +environment: + EDITION: oss image_pull_secrets: - dockerconfigjson kind: pipeline @@ -4637,7 +5183,6 @@ steps: - build-backend - build-frontend - build-frontend-packages - - build-backend-enterprise2 environment: GPG_KEY_PASSWORD: from_secret: gpg_key_password @@ -4764,7 +5309,7 @@ steps: - success - failure - commands: - - ./bin/grabpl upload-cdn --edition enterprise + - ./bin/build upload-cdn --edition enterprise depends_on: - package environment: @@ -4778,7 +5323,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl upload-packages --edition enterprise + - ./bin/build upload-packages --edition enterprise depends_on: - package environment: @@ -4796,10 +5341,9 @@ steps: --variants linux-amd64 --sign depends_on: - build-plugins - - build-backend + - build-backend-enterprise2 - build-frontend - build-frontend-packages - - build-backend-enterprise2 environment: GPG_KEY_PASSWORD: from_secret: gpg_key_password @@ -4812,7 +5356,7 @@ steps: image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - - ./bin/grabpl upload-cdn --edition enterprise2 + - ./bin/build upload-cdn --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -4823,7 +5367,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: upload-cdn-assets-enterprise2 - commands: - - ./bin/grabpl upload-packages --edition enterprise2 + - ./bin/build upload-packages --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -4851,6 +5395,8 @@ volumes: clone: disable: true depends_on: [] +environment: + EDITION: enterprise image_pull_secrets: - dockerconfigjson kind: pipeline @@ -5274,6 +5820,254 @@ volumes: path: /var/run/docker.sock name: docker --- +clone: + disable: true +depends_on: [] +environment: + EDITION: enterprise2 +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: release-branch-enterprise2-build-e2e-publish +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner +- commands: + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_BRANCH} + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: clone-enterprise +- commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + depends_on: + - clone-enterprise + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.6.4 + name: init-enterprise +- commands: + - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd + depends_on: + - init-enterprise + environment: + CGO_ENABLED: 0 + image: golang:1.19.3 + name: compile-build-cmd +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: yarn-install +- commands: + - '# It is required that code generated from Thema/CUE be committed and in sync + with its inputs.' + - '# The following command will fail if running code generators produces any diff + in output.' + - CODEGEN_VERIFY=1 make gen-cue + depends_on: + - init-enterprise + image: grafana/build-container:1.6.4 + name: verify-gen-cue +- commands: + - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend +- commands: + - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - compile-build-cmd + - yarn-install + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.6.4 + name: build-frontend-packages +- commands: + - ./bin/build build-plugins --jobs 8 --edition enterprise + depends_on: + - compile-build-cmd + - yarn-install + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: build-plugins +- commands: + - ./bin/build build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} + --variants linux-amd64 + depends_on: + - wire-install + - compile-build-cmd + image: grafana/build-container:1.6.4 + name: build-backend-enterprise2 +- commands: + - ./bin/build package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} + --variants linux-amd64 --sign + depends_on: + - build-plugins + - build-backend-enterprise2 + - build-frontend + - build-frontend-packages + environment: + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + image: grafana/build-container:1.6.4 + name: package-enterprise2 +- commands: + - ./bin/build upload-cdn --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + ENTERPRISE2_CDN_PATH: + from_secret: enterprise2-cdn-path + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-cdn-assets-enterprise2 +- commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package-enterprise2 + image: grafana/build-container:1.6.4 + name: copy-packages-for-docker +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build build-docker --edition enterprise2 --shouldSave --ubuntu + depends_on: + - copy-packages-for-docker + - compile-build-cmd + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker fetch --edition enterprise2 + depends_on: + - build-docker-images + - build-docker-images-ubuntu + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build artifacts docker publish-enterprise2 --dockerhub-repo $${DOCKER_ENTERPRISE2_REPO} + depends_on: + - fetch-images-enterprise2 + environment: + DOCKER_ENTERPRISE2_REPO: + from_secret: docker_enterprise2_repo + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key_hg + image: google/cloud-sdk + name: publish-images-enterprise2 + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/build upload-packages --edition enterprise2 + depends_on: + - package-enterprise2 + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.3 + name: upload-packages-enterprise2 +trigger: + ref: + - refs/heads/v[0-9]* +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- clone: retries: 3 kind: pipeline @@ -5512,6 +6306,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 77ae647c9addfcd9966d462ca9967b85a87e18adfe0e9e0a2c6b1cf5d7f42493 +hmac: e7746a4b35fba9e1a7cb3096b947a874786b082f41e4252448ac6acde7ee3ccf ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 92dfa9ee7c5..b7b617c6eac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -114,6 +114,8 @@ go.sum @grafana/backend-platform /contribute/architecture/backend @grafana/backend-platform /contribute/engineering/backend @grafana/backend-platform +package.json @grafana/frontend-ops +tsconfig.json @grafana/frontend-ops /crowdin.yml @grafana/user-essentials /public/locales @grafana/user-essentials /public/app/core/internationalization @grafana/user-essentials @@ -167,8 +169,6 @@ go.sum @grafana/backend-platform /scripts/grunt @grafana/frontend-ops /scripts/webpack @grafana/frontend-ops /scripts/generate-a11y-report.sh @grafana/user-essentials -package.json @grafana/frontend-ops -tsconfig.json @grafana/frontend-ops lerna.json @grafana/frontend-ops .babelrc @grafana/frontend-ops .prettierrc.js @grafana/frontend-ops @@ -205,6 +205,7 @@ lerna.json @grafana/frontend-ops /grafana-mixin/ @grafana/hosted-grafana-team # Grafana authentication and authorization +/pkg/login @grafana/grafana-authnz-team /pkg/services/accesscontrol @grafana/grafana-authnz-team /pkg/services/auth @grafana/grafana-authnz-team /pkg/services/dashboards/accesscontrol.go @grafana/grafana-authnz-team @@ -217,6 +218,7 @@ lerna.json @grafana/frontend-ops /pkg/services/oauthtoken @grafana/grafana-authnz-team /pkg/services/teamguardian @grafana/grafana-authnz-team /pkg/services/serviceaccounts @grafana/grafana-authnz-team +/pkg/services/loginattempt @grafana/grafana-authnz-team # Grafana Partnerships Team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team diff --git a/.gitignore b/.gitignore index c7f6ce8ec9e..a4c02bfa4ca 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ debug.test /packaging/**/*.tar.gz /packaging/**/*.tar.gz.sha256 pkg/cmd/grafana-server/__debug_bin +pkg/services/quota/quotaimpl/storage/storage.json # Ignore OSX indexing .DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json index 22f7c49d9a9..a6b91b2f521 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,10 +6,10 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/pkg/cmd/grafana-server/", + "program": "${workspaceFolder}/pkg/cmd/grafana/", "env": {}, "cwd": "${workspaceFolder}", - "args": ["--homepath", "${workspaceFolder}", "--packaging", "dev"] + "args": ["server", "--homepath", "${workspaceFolder}", "--packaging", "dev"] }, { "name": "Attach to Chrome", diff --git a/.yarn/cache/rst2html-https-e87da7ea2f-63d5ff3068.zip b/.yarn/cache/rst2html-https-e87da7ea2f-63d5ff3068.zip new file mode 100644 index 00000000000..245557fb351 Binary files /dev/null and b/.yarn/cache/rst2html-https-e87da7ea2f-63d5ff3068.zip differ diff --git a/.yarn/cache/tether-drop-https-3382d2649f-a10a7337d5.zip b/.yarn/cache/tether-drop-https-3382d2649f-a10a7337d5.zip new file mode 100644 index 00000000000..1602b2e6742 Binary files /dev/null and b/.yarn/cache/tether-drop-https-3382d2649f-a10a7337d5.zip differ diff --git a/CHANGELOG.md b/CHANGELOG.md index c1efe236831..f8ed5a9e9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,24 @@ The following functions and classes related to logs are deprecated in the `grafa - **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo) - **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart) + + +# 9.2.6 (2022-11-22) + +### Features and enhancements + +- **Alerting:** Support Prometheus durations in Provisioning API. [#58293](https://github.com/grafana/grafana/pull/58293), [@bartpeeters](https://github.com/bartpeeters) +- **SSE:** Keep value name from numeric table. [#58831](https://github.com/grafana/grafana/pull/58831), [@kylebrandt](https://github.com/kylebrandt) +- **Transformations:** Make Card Descriptions Clickable. [#58717](https://github.com/grafana/grafana/pull/58717), [@zuchka](https://github.com/zuchka) + +### Bug fixes + +- **MS/My/PostgresSQL:** Migrate annotation query. [#58847](https://github.com/grafana/grafana/pull/58847), [@zoltanbedi](https://github.com/zoltanbedi) +- **Search:** Fixes issue with Recent/Starred section always displaying "General" folder. [#58746](https://github.com/grafana/grafana/pull/58746), [@JoaoSilvaGrafana](https://github.com/JoaoSilvaGrafana) +- **Server:** Write internal server error on missing write. [#57813](https://github.com/grafana/grafana/pull/57813), [@sakjur](https://github.com/sakjur) + + + # 9.2.5 (2022-11-16) diff --git a/Dockerfile b/Dockerfile index 1032ba60ae7..966aebfa347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,7 +82,7 @@ RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" -COPY --from=go-builder /grafana/bin/*/grafana-server /grafana/bin/*/grafana-cli ./bin/ +COPY --from=go-builder /grafana/bin/*/grafana-server /grafana/bin/*/grafana-cli /grafana/bin/*/grafana ./bin/ COPY --from=js-builder /grafana/public ./public COPY --from=js-builder /grafana/tools ./tools diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 077d97a0c99..a6b62ea1e25 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -77,7 +77,7 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" -COPY --from=go-builder /src/grafana/bin/*/grafana-server /src/grafana/bin/*/grafana-cli bin/ +COPY --from=go-builder /src/grafana/bin/*/grafana-server /src/grafana/bin/*/grafana-cli /grafana/bin/*/grafana bin/ COPY --from=js-builder /usr/src/app/public public COPY --from=js-builder /usr/src/app/tools tools diff --git a/Makefile b/Makefile index 03a2c40b48b..18c873b8ad2 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ WIRE_TAGS = "oss" -include local/Makefile include .bingo/Variables.mk -.PHONY: all deps-go deps-js deps build-go build-server build-cli build-js build build-docker-full build-docker-full-ubuntu lint-go golangci-lint test-go test-js gen-ts test run run-frontend clean devenv devenv-down protobuf drone help gen-go gen-cue +.PHONY: all deps-go deps-js deps build-go build-backend build-server build-cli build-js build build-docker-full build-docker-full-ubuntu lint-go golangci-lint test-go test-js gen-ts test run run-frontend clean devenv devenv-down protobuf drone help gen-go gen-cue GO = go GO_FILES ?= ./pkg/... @@ -82,6 +82,10 @@ build-go: $(MERGED_SPEC_TARGET) gen-go ## Build all Go binaries. @echo "build go files" $(GO) run build.go $(GO_BUILD_FLAGS) build +build-backend: ## Build Grafana backend. + @echo "build backend" + $(GO) run build.go $(GO_BUILD_FLAGS) build-backend + build-server: ## Build Grafana server. @echo "build server" $(GO) run build.go $(GO_BUILD_FLAGS) build-server diff --git a/SECURITY.md b/SECURITY.md index b8697c164d6..e5672d2316c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,6 @@ Grafana Labs will send you a response indicating the next steps in handling your ## Security announcements -We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/support/security-announcements), -where we will post a summary, remediation, and mitigation details for any patch containing security fixes. +We will post a summary, remediation, and mitigation details for any patch containing security fixes on the Grafana blog. The security announcement blog posts will be tagged with the [security tag](https://grafana.com/tags/security/). -You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss). +You can also track security announcements via the [RSS feed](https://grafana.com/tags/security/index.xml). diff --git a/conf/defaults.ini b/conf/defaults.ini index 023d23a53cb..49c4582a280 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -412,8 +412,8 @@ password_hint = password # Default UI theme ("dark" or "light") default_theme = dark -# Default locale (supported IETF language tag, such as en-US) -default_locale = en-US +# Default UI language (supported IETF language tag, such as en-US) +default_language = en-US # Path to a custom home page. Users are only redirected to this if the default home dashboard is used. It should match a frontend route and contain a leading slash. home_page = @@ -435,6 +435,10 @@ user_invite_max_lifetime_duration = 24h # Enter a comma-separated list of usernames to hide them in the Grafana UI. These users are shown to Grafana admins and to themselves. hidden_users = +[service_accounts] +# When set, Grafana will not allow the creation of tokens with expiry greater than this setting. +token_expiration_day_limit = + [auth] # Login cookie name login_cookie_name = grafana_session @@ -1268,6 +1272,8 @@ license_path = # enable = feature1,feature2 enable = +internationalization = true + # feature1 = true # feature2 = false diff --git a/conf/sample.ini b/conf/sample.ini index aacf119127c..7c27b34819b 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -412,8 +412,8 @@ # Default UI theme ("dark" or "light") ;default_theme = dark -# Default locale (supported IETF language tag, such as en-US) -;default_locale = en-US +# Default UI language (supported IETF language tag, such as en-US) +;default_language = en-US # Path to a custom home page. Users are only redirected to this if the default home dashboard is used. It should match a frontend route and contain a leading slash. ;home_page = @@ -435,6 +435,11 @@ # Enter a comma-separated list of users login to hide them in the Grafana UI. These users are shown to Grafana admins and themselves. ; hidden_users = +[service_accounts] +# Service account maximum expiration date in days. +# When set, Grafana will not allow the creation of tokens with expiry greater than this setting. +; token_expiration_day_limit = + [auth] # Login cookie name ;login_cookie_name = grafana_session diff --git a/contribute/merge-pull-request.md b/contribute/merge-pull-request.md index 365f77d3661..69e2781fd4b 100644 --- a/contribute/merge-pull-request.md +++ b/contribute/merge-pull-request.md @@ -135,6 +135,14 @@ Some examples when backport is not required: - The change is supposed to be released in the next major/minor release, e.g. v8.0.0, but the release branch, e.g. v8.0.x, has not yet been created. +#### Required labels + +To ensure that we don't backport pull requests that don't need to be backported, i.e. implement new features, and only backport pull requests that address bugs, have a product approval, or refer to docs changes, backport labels need to be followed by either: + +- `type/bug` label: Pull requests which address bugs, +- `product-approved` label: Urgent fixes which need product approval, in order to get merged, +- `type/docs` label: Docs changes`. + > **Note:** You can still backport a pull request after it's been merged. ## Doing the actual merge diff --git a/devenv/README.md b/devenv/README.md index 6d6a7065b87..b8cbc39c8e9 100644 --- a/devenv/README.md +++ b/devenv/README.md @@ -4,6 +4,7 @@ This folder contains useful scripts and configuration so you can: - Configure data sources in Grafana for development. - Configure dashboards for development and test scenarios. +- Set up an SMTP Server + Web Interface for viewing and testing emails. - Create docker-compose file with databases and fake data. ## Install Docker @@ -58,6 +59,21 @@ Jaeger block runs both Jaeger and Loki container. Loki container sends traces to | 1.0 | graphite1 | 8280 | 2203 | 2203 | | 0.9 | graphite09 | 8380 | 2303 | 2303 | +#### MailDev + +MailDev block runs an SMTP server and a web UI to test and view emails. This is useful for testing your email notifications locally. + +Make sure you configure your .ini file with the following settings: + +```ini +[smtp] +enabled = true +skip_verify = true +host = "localhost:1025" +``` + +You can access the web UI at http://localhost:12080/#/ + ## Debugging setup in VS Code An example of launch.json is provided in `devenv/vscode/launch.json`. It basically does what Makefile and .bra.toml do. The 'program' field is set to the folder name so VS Code loads all *.go files in it instead of just main.go. diff --git a/devenv/docker/blocks/loki/docker-compose.yaml b/devenv/docker/blocks/loki/docker-compose.yaml index ce7aeb20abc..b4985f53085 100644 --- a/devenv/docker/blocks/loki/docker-compose.yaml +++ b/devenv/docker/blocks/loki/docker-compose.yaml @@ -1,5 +1,8 @@ loki: image: grafana/loki:latest + command: -config.file=/etc/loki/loki-config.yaml + volumes: + - ./docker/blocks/loki/loki-config.yaml:/etc/loki/loki-config.yaml ports: - "3100:3100" diff --git a/devenv/docker/blocks/loki/loki-config.yaml b/devenv/docker/blocks/loki/loki-config.yaml new file mode 100644 index 00000000000..9feccdfe545 --- /dev/null +++ b/devenv/docker/blocks/loki/loki-config.yaml @@ -0,0 +1,44 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://localhost:9093 + + +# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration +# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/ +# +# Statistics help us better understand how Loki is used, and they show us performance +# levels for most users. This helps us prioritize features and documentation. +# For more information on what's sent, look at +# https://github.com/grafana/loki/blob/main/pkg/usagestats/stats.go +# Refer to the buildReport method to see what goes into a report. +# +# If you would like to disable reporting, uncomment the following lines: +#analytics: +# reporting_enabled: false diff --git a/docs/sources/administration/data-source-management/index.md b/docs/sources/administration/data-source-management/index.md index 3f2b5d7521a..42d56c8fda3 100644 --- a/docs/sources/administration/data-source-management/index.md +++ b/docs/sources/administration/data-source-management/index.md @@ -27,9 +27,6 @@ Before you can create your first dashboard, you need to add your data source. **To add a data source:** 1. Select the cog icon on the side menu to show the configuration options. - - {{< figure src="/static/img/docs/v75/sidemenu-datasource-7-5.png" max-width="150px" class="docs-image--no-shadow">}} - 1. Select **Data sources**. This opens the data sources page, which displays a list of previously configured data sources for the Grafana instance. @@ -44,9 +41,7 @@ Before you can create your first dashboard, you need to add your data source. 1. Move the cursor over the data source you want to add. - {{< figure src="/static/img/docs/v75/select-data-source-7-5.png" max-width="700px" class="docs-image--no-shadow">}} - -1. Select **Select**. +1. Click **Select**. This opens the data source configuration page. @@ -63,8 +58,6 @@ Each data source's configuration includes a permissions page where you can enabl ### Enable data source permissions -{{< figure src="/static/img/docs/enterprise/datasource_permissions_enable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_enable.gif" >}} - By default, data sources in an organization can be queried by any user in that organization. For example, a user with the `Viewer` role can issue any possible query to a data source, not just queries that exist on dashboards they have access to. @@ -82,8 +75,6 @@ When permissions are enabled for a data source in an organization, the user who ### Allow users and teams to query a data source -{{< figure src="/static/img/docs/enterprise/datasource_permissions_add_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_add.gif" >}} - After you have enabled permissions for a data source you can assign query permissions to users and teams which will allow access to query the data source. **Assign query permission to users and teams:** @@ -98,8 +89,6 @@ After you have enabled permissions for a data source you can assign query permis ### Disable data source permissions -{{< figure src="/static/img/docs/enterprise/datasource_permissions_disable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/static/img/docs/enterprise/datasource_permissions_disable.gif" >}} - If you have enabled permissions for a data source and want to return data source permissions to the default, then you can disable permissions with a click of a button. Note that _all_ existing permissions created for the data source will be deleted. diff --git a/docs/sources/administration/organization-management/index.md b/docs/sources/administration/organization-management/index.md index f8e8fa37cad..9e9724bb639 100644 --- a/docs/sources/administration/organization-management/index.md +++ b/docs/sources/administration/organization-management/index.md @@ -60,8 +60,6 @@ Complete this task when you want to view a list of existing organizations. A list of organizations appears. -![Server Admin organization list](/static/img/docs/manage-users/server-org-list-7-3.png) - ## Create an organization Create an organization when you want to isolate dashboards and other resources from each other. @@ -117,5 +115,3 @@ Edit an organization when you want to change its name. 1. Hover your cursor over the **Server Admin** (shield) icon until a menu appears, and click **Orgs**. 1. Click the organization you want to edit. 1. Update the organization name and click **Update**. - -![Server admin Edit Organization](/static/img/docs/manage-users/server-admin-edit-org-7-3.png) diff --git a/docs/sources/administration/plugin-management/index.md b/docs/sources/administration/plugin-management/index.md index 5bd9ad9df65..f23ca023be1 100644 --- a/docs/sources/administration/plugin-management/index.md +++ b/docs/sources/administration/plugin-management/index.md @@ -88,8 +88,6 @@ To browse for available plugins: 1. Click the **All** filter to browse all available plugins. 1. Click the **Data sources**, **Panels**, or **Applications** buttons to filter by plugin type. -![Plugin catalog browse](/static/img/docs/plugins/plugins-catalog-browse-9.2.png) - ### Install a plugin To install a plugin: @@ -101,8 +99,6 @@ To install a plugin: When the update is complete, you see a confirmation message that the installation was successful. -![Plugin catalog install](/static/img/docs/plugins/plugins-catalog-install-8-1.png) - ### Update a plugin To update a plugin: @@ -113,8 +109,6 @@ To update a plugin: When the update is complete, you see a confirmation message that the update was successful. -![Plugin catalog update](/static/img/docs/plugins/plugins-catalog-update-8-1.png) - ### Uninstall a plugin To uninstall a plugin: @@ -125,8 +119,6 @@ To uninstall a plugin: When the update is complete, you see a confirmation message that the uninstall was successful. -![Plugin catalog uninstall](/static/img/docs/plugins/plugins-catalog-uninstall-8-1.png) - ## Install Grafana plugins Grafana supports data source, panel, and app plugins. Having panels as plugins makes it easy to create and add any kind of panel, to show your data, or improve your favorite dashboards. Apps enable the bundling of data sources, panels, dashboards, and Grafana pages into a cohesive experience. diff --git a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md index b3c0120479a..a46b511a8b2 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md @@ -53,8 +53,6 @@ In both cases, the assignment applies only to the user, team or service account 4. In the **Role** column, select the fixed role that you want to assign to the user, team or service account. 5. Click **Update**. -![User role picker in an organization](/static/img/docs/enterprise/user_role_picker_in_org.png) - **To assign a fixed role as a server administrator:** 1. Sign in to Grafana, hover your cursor over **Server Admin** (the shield icon) in the left navigation menu, and click **Users**. @@ -62,8 +60,6 @@ In both cases, the assignment applies only to the user, team or service account 1. In the **Organizations** section, select a role within an organization that you want to assign to the user. 1. Click **Update**. -![User role picker in Organization](/static/img/docs/enterprise/user_role_picker_global.png) - ## Assign fixed or custom roles to a team using provisioning Instead of using the Grafana role picker, you can use file-based provisioning to assign fixed roles to teams. If you have a large number of teams, provisioning can provide an easier approach to assigning and managing role assignments. diff --git a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md index 99cbacf35a9..05f9e61cbce 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/configure-rbac/index.md @@ -14,9 +14,10 @@ weight: 30 The table below describes all RBAC configuration options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../../../setup-grafana/configure-grafana/#configure-with-environment-variables" >}}). -| Setting | Required | Description | Default | -| ------------------ | -------- | ---------------------------------------------------------------------------- | ------- | -| `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` | +| Setting | Required | Description | Default | +| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `permission_cache` | No | Enable to use in memory cache for loading and evaluating users' permissions. | `true` | +| `permission_validation_enabled` | No | Grafana enforces validation for permissions when a user creates or updates a role. The system checks the internal list of scopes and actions for each permission to determine they are valid. By default, if a scope or action is not recognized, Grafana logs a warning message. When set to `true`, Grafana returns an error. | `false` | ## Example RBAC configuration diff --git a/docs/sources/administration/service-accounts/index.md b/docs/sources/administration/service-accounts/index.md index 7ed85d7a5a1..26f9429f50f 100644 --- a/docs/sources/administration/service-accounts/index.md +++ b/docs/sources/administration/service-accounts/index.md @@ -89,6 +89,10 @@ You can create a service account token using the Grafana UI or via the API. For - Ensure you have permission to create and edit service accounts. By default, the organization administrator role is required to create and edit service accounts. For more information about user permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/#" >}}). +### Service account token expiration dates + +By default, service account tokens don't have an expiration date, meaning they won't expire at all. However, if `token_expiration_day_limit` is set to a value greater than 0, Grafana restricts the lifetime limit of new tokens to the configured value in days. + ### To add a token to a service account 1. Sign in to Grafana, then hover your cursor over **Configuration** (the gear icon) in the sidebar. diff --git a/docs/sources/administration/team-management/index.md b/docs/sources/administration/team-management/index.md index 38c157e622e..3259eac267a 100644 --- a/docs/sources/administration/team-management/index.md +++ b/docs/sources/administration/team-management/index.md @@ -62,8 +62,6 @@ To add a team member: 1. Choose if you want to add the user as a team Member or an Admin. 1. Click **Add to team**. -![Add team member](/static/img/docs/manage-users/add-team-member-7-3.png) - ## Grant team member permissions Complete this task when you want to add or modify team member permissions. @@ -76,8 +74,6 @@ To grant team member permissions: 1. In the team member list, find and click the user that you want to change. You can use the search field to filter the list if necessary. 1. Click the **Permission** list, and then click the new user permission level. -![Change team member permissions](/static/img/docs/manage-users/change-team-permissions-7-3.png) - ## Remove a team member You can remove a team member when you no longer want to apply team permissions to the user diff --git a/docs/sources/administration/user-management/manage-org-users/index.md b/docs/sources/administration/user-management/manage-org-users/index.md index 349cbbb8b2e..a0c2118393d 100644 --- a/docs/sources/administration/user-management/manage-org-users/index.md +++ b/docs/sources/administration/user-management/manage-org-users/index.md @@ -33,8 +33,6 @@ You can see a list of users with accounts in your Grafana organization. If neces 1. Sign in to Grafana as an organization administrator. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu and click **Users**. -![Org Admin user list](/static/img/docs/manage-users/org-user-list-7-3.png) - > **Note:** If you have [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can also [view a global list of users]({{< relref "../server-user-management#view-a-list-of-users" >}}) in the Server Admin section of Grafana. ## Change a user's organization permissions @@ -96,8 +94,6 @@ When you invite users to join an organization, you assign the **Admin**, **Edito If the invitee is not already a user, the system adds them. -![Invite User](/static/img/docs/manage-users/org-invite-user-7-3.png). - ## Manage a pending invitation Periodically review invitations you have sent so that you can see a list of users that have not yet accepted the invitation or cancel a pending invitation. @@ -116,14 +112,10 @@ Periodically review invitations you have sent so that you can see a list of user The **Pending Invites** button appears only when there are unaccepted invitations. - ![Pending Invites button](/static/img/docs/manage-users/pending-invites-button-7-3.png) - To cancel an invitation, click the red **X** next to the invitation. To copy an invitation link and send it directly to a user, click Copy Invite. You can then paste the invite link into a message. -![Pending Invites list](/static/img/docs/manage-users/pending-invites-list-7-3.png) - ## Remove a user from an organization You can remove a user from an organization when they no longer require access to the dashboard or data sources owned by the organization. No longer requiring access to an organization might occur when the user has left your company or has internally moved to another organization. diff --git a/docs/sources/administration/user-management/server-user-management/_index.md b/docs/sources/administration/user-management/server-user-management/_index.md index c75da586f05..47fba204f1f 100644 --- a/docs/sources/administration/user-management/server-user-management/_index.md +++ b/docs/sources/administration/user-management/server-user-management/_index.md @@ -38,8 +38,6 @@ You can see a list of users with accounts on your Grafana server. This action mi 1. Sign in to Grafana as a server administrator. 1. Hover your cursor over the **Server Admin** (shield) icon until a menu appears, and click **Users**. -![Server Admin user list](/static/img/docs/manage-users/server-user-list-7-3.png) - > **Note:** If you have [organization administrator]({{< relref "../../roles-and-permissions/#organization-roles" >}}) permissions and _not_ [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can still [view of list of users in a given organization]({{< relref "../manage-org-users/#view-a-list-of-organization-users" >}}). ## View user details @@ -62,26 +60,18 @@ A user account contains the following sections. This section contains basic user information, which users can update. -![Server Admin user information section](/static/img/docs/manage-users/server-admin-user-information-7-3.png) - #### Permissions This indicates whether the user account has the Grafana administrator flag applied. If the flag is set to **Yes**, then the user is a Grafana server administrator. -![Server Admin Permissions section](/static/img/docs/manage-users/server-admin-permissions-7-3.png) - #### Organizations This section lists the organizations the user belongs to and their assigned role. -![Server Admin Organizations section](/static/img/docs/manage-users/server-admin-organisations-7-3.png) - #### Sessions This section includes recent user sessions and information about the time the user logged in and they system they used. You can force logouts, if necessary. -![Server Admin Sessions section](/static/img/docs/manage-users/server-admin-sessions-7-3.png) - ## Edit a user account Edit a user account when you want to modify user login credentials, or delete, disable, or enable a user. diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index 67d9736dc13..21bb21782d5 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -15,7 +15,7 @@ weight: 400 # Create a Grafana managed alerting rule -Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fix thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting. +Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fixed thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting. Watch this video to learn more about creating alerts: {{< vimeo 720001934 >}} @@ -23,26 +23,28 @@ Watch this video to learn more about creating alerts: {{< vimeo 720001934 >}} 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. The new alerting rule page opens where the Grafana managed alerts option is selected by default. -1. In Step 1, add queries and expressions to evaluate, and then select the alert condition. +1. In Step 1, add the rule name. + - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 2, add queries and expressions to evaluate, and then select the alert condition. - For queries, select a data source from the drop-down. - Add one or more [queries]({{< relref "../../panels-visualizations/query-transform-data/#add-a-query/" >}}) or [expressions]({{< relref "../../panels-visualizations/query-transform-data/expression-queries/" >}}). - For each expression, select either **Classic condition** to create a single alert rule, or choose from **Math**, **Reduce**, **Resample** options to generate separate alert for each series. For details on these options, see [Single and multi dimensional rule](#single-and-multi-dimensional-rule). - Click **Run queries** to verify that the query is successful. - Next, select the query or expression for your alert condition. -1. In Step 2, specify the alert evaluation interval. +1. In Step 3, specify the alert evaluation interval. - From the **Condition** drop-down, select the query or expression to trigger the alert rule. - For **Evaluate every**, specify the frequency of evaluation. Must be a multiple of 10 seconds. For examples, `1m`, `30s`. - For **Evaluate for**, specify the duration for which the condition must be true before an alert fires. > **Note:** Once a condition is breached, the alert goes into the Pending state. If the condition remains breached for the duration specified, the alert transitions to the `Firing` state, otherwise it reverts back to the `Normal` state. - In **Configure no data and error handling**, configure alerting behavior in the absence of data. Use the guidelines in [No data and error handling](#no-data-and-error-handling). - Click **Preview alerts** to check the result of running the query at this moment. Preview excludes no data and error handling. -1. In Step 3, add the rule name, storage location, rule group, as well as additional metadata associated with the rule. - - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 4, add the storage location, rule group, as well as additional metadata associated with the rule. - From the **Folder** drop-down, select the folder where you want to store the rule. - For **Group**, specify a pre-defined group. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. - Add a description and summary to customize alert messages. Use the guidelines in [Annotations and labels for alerting]({{< relref "../fundamentals/annotation-label/" >}}). - Add Runbook URL, panel, dashboard, and alert IDs. - - Add custom labels. +1. In Step 5, add custom labels. + - Add custom labels selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value . 1. Click **Save** to save the rule or **Save and exit** to save the rule and go back to the Alerting page. 1. Next, create a for the rule. diff --git a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md index 43a6fe19584..9e07626fc4c 100644 --- a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md +++ b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md @@ -37,11 +37,12 @@ To create a Grafana Mimir or Loki managed recording rule 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. The new alerting rule page opens where the **Grafana managed alert** option is selected by default. -1. In Step 1, select **Mimir or Loki recording rule** option. +1. In Step 1, add the rule name. + - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 2, select **Mimir or Loki recording rule** option. - Select your Loki or Prometheus data source, add the query to evaluate, and then select the alert condition. - Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. -1. In Step 2, add the rule name, namespace, rule group, as well as additional metadata associated with the rule. - - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 3, add the namespace, rule group, as well as additional metadata associated with the rule. - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click Add new and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "edit-mimir-loki-namespace-group/" >}}). - From the **Group** drop-down, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. - Add a description and summary to customize alert messages. Use the guidelines in [Annotations and labels for alerting]({{< relref "../fundamentals/annotation-label/" >}}). @@ -52,18 +53,20 @@ To create a Grafana Mimir or Loki managed recording rule 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. -1. In Step 1, add the rule name, type, and storage location. +1. In Step 1, add the rule name. - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 2, add the type, and storage location. - From the **Rule type** drop-down, select **Mimir / Loki managed alert**. - From the **Select data source** drop-down, select an external Prometheus, an external Loki, or a Grafana Cloud data source. - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click **Add new** and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. - From the **Group** drop-down, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-alert-type-8-0.png" max-width="550px" caption="Alert details" >}} -1. In Step 2, add the query to evaluate. +1. In Step 3, add the query to evaluate. - Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-query-8-0.png" max-width="550px" caption="Alert details" >}} -1. In Step 3, add additional metadata associated with the rule. +1. In Step 4, add additional metadata associated with the rule. - Add a description and summary to customize alert messages. Use the guidelines in [Annotations and labels for alerting]({{< relref "../fundamentals/annotation-label/" >}}). - Add Runbook URL, panel, dashboard, and alert IDs. - - Add custom labels. +1. In Step 5, add custom labels. + - Add custom labels selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value . 1. Click **Save** to save the rule or **Save and exit** to save the rule and go back to the Alerting page. diff --git a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md index 379c0df080d..89b7def3c5d 100644 --- a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-rule.md @@ -37,18 +37,20 @@ Watch this video to learn more about how to create a Mimir managed alert rule: { 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. The new alerting rule page opens where the Grafana managed alerts option is selected by default. -1. In Step 1, select **Mimir or Loki alert** option. +1. In Step 1, add the rule name. + - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 2, select **Mimir or Loki alert** option. - Next, select your Loki or Prometheus data source and add the query to evaluate. - Enter a PromQL or LogQL expression to query. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. -1. In Step 2, specify the alert evaluation interval. +1. In Step 3, specify the alert evaluation interval. - In the **For** text box, specify the duration for which the condition must be true before an alert fires. If you specify `5m`, the condition must be true for 5 minutes before the alert fires. > **Note:** Once a condition is met, the alert goes into the `Pending` state. If the condition remains active for the duration specified, the alert transitions to the `Firing` state, else it reverts to the `Normal` state. -1. In Step 3, add the rule name, namespace, rule group, as well as additional metadata associated with the rule. - - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. +1. In Step 4, add the namespace, rule group, as well as additional metadata associated with the rule. - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click **Add new** and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "edit-mimir-loki-namespace-group/" >}}). - From the **Group** drop-down, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. - Add a description and summary to customize alert messages. Use the guidelines in [Annotations and labels for alerting]({{< relref "../fundamentals/annotation-label/" >}}). - Add Runbook URL, panel, dashboard, and alert IDs. - - Add custom labels. +1. In Step 5, add custom labels. + - Add custom labels selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value . 1. Click **Save** to save the rule or **Save and exit** to save the rule and go back to the Alerting page. 1. Next, create a notification for the rule. diff --git a/docs/sources/alerting/manage-notifications/images-in-notifications.md b/docs/sources/alerting/manage-notifications/images-in-notifications.md index e2dc58e7778..5db84b342fb 100644 --- a/docs/sources/alerting/manage-notifications/images-in-notifications.md +++ b/docs/sources/alerting/manage-notifications/images-in-notifications.md @@ -54,7 +54,7 @@ If Grafana has been set up to use an external image store, `upload_external_imag # will be persisted to disk for up to temp_data_lifetime. upload_external_image_storage = false -Restart Grafana for the changes to take affect. +Restart Grafana for the changes to take effect. ## Supported notifiers @@ -88,8 +88,8 @@ Include images from URL refers to using the external image store. Grafana provides the following metrics to observe the performance and failure rate of images in notifications. For example, if a screenshot could not be taken within the expected time (10 seconds) then the counter `grafana_screenshot_failures_total` is updated. -- `grafana_screenshot_cache_hits_total` -- `grafana_screenshot_cache_misses_total` +- `grafana_alerting_image_cache_hits_total` +- `grafana_alerting_image_cache_misses_total` - `grafana_screenshot_duration_seconds` - `grafana_screenshot_failures_total` - `grafana_screenshot_successes_total` @@ -101,3 +101,14 @@ For example, if a screenshot could not be taken within the expected time (10 sec - Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager. - When alerts generated by different alert rules are sent in a single notification, there may be screenshots for each alert rule. This happens if an alert group contains multiple alerting rules. The order the images are attached is random. If you need to guarantee the ordering of images, make sure that your alert groups contain a single alerting rule. - Some contact points only handle a single image. In this case, the first image associated with an alert will be attached. Because the ordering is random, this may not always be an image for the same alert rule. If you need to guarantee you receive a screenshot for a particular rule, make sure that your alert groups contain a single alerting rule. + +## Troubleshooting + +If Grafana has been set up to send images in notifications, however notifications are still being received without images, then check the troubleshooting instructions below. + +1. Check that Grafana has been set up as per the instructions +2. Check that the notifier supports images in notifications as per the table of supported notifiers +3. Enable debug logging in Grafana and look for logs with the logger `ngalert.image` +4. If the alert is not associated with a dashboard there will be logs for `Cannot take screenshot for alert rule as it is not associated with a dashboard` +5. If the alert is associated with a dashboard, but no panel in the dashboard, there will be logs for `Cannot take screenshot for alert rule as it is not associated with a panel` +6. If images cannot be taken because of mis-configuration or an issue with image rendering there will be logs for `Failed to take an image` including the Dashboard UID, Panel ID, and the error message diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md index 151e93f4ef4..9da57092d03 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md @@ -21,15 +21,25 @@ There are three options to choose from: 1. Use file provisioning to provision your Grafana Alerting resources, such as alert rules and contact points, through files on disk. -1. Provision your alerting resources using the Grafana HTTP API. +1. Provision your alerting resources using the Alerting Provisioning HTTP API. - For more information on the Grafana Alerting provisioning API, refer to [Alerting provisioning API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/). + For more information on the Alerting Provisioning HTTP API, refer to [Alerting provisioning API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/). + + **Note:** + + Typically, you cannot edit API-provisioned alert rules from the Grafana UI. + + In order to enable editing, add the x-disable-provenance header to the following requests when creating or editing your alert rules in the API: + + POST /api/v1/provisioning/alert-rules + + PUT /api/v1/provisioning/alert-rules/{UID} 1. Provision your alerting resources using Terraform. **Note:** -Currently, provisioning for Grafana Alerting supports alert rules, contact points, mute timings, and templates. Provisioned alerting resources can only be edited in the source that created them and not from within Grafana or any other source. For example, if you provision your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana. +Currently, provisioning for Grafana Alerting supports alert rules, contact points, mute timings, and templates. Provisioned alerting resources using file provisioning or Terraform can only be edited in the source that created them and not from within Grafana or any other source. For example, if you provision your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana. **Useful Links:** diff --git a/docs/sources/developers/http_api/annotations.md b/docs/sources/developers/http_api/annotations.md index a7314df7905..08a1d432760 100644 --- a/docs/sources/developers/http_api/annotations.md +++ b/docs/sources/developers/http_api/annotations.md @@ -16,7 +16,7 @@ title: 'Annotations HTTP API' # Annotations API -This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. +Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. > If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions]({{< relref "../../administration/roles-and-permissions/access-control/custom-role-actions-scopes/" >}}) for more information. diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index a1ed663bb12..3b14918828d 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -61,7 +61,7 @@ Grafana Enterprise adds the following features: - [Custom branding]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding/" >}}) to customize Grafana from the brand and logo to the footer links. - [Usage insights]({{< relref "../dashboards/assess-dashboard-usage/" >}}) to understand how your Grafana instance is used. - [Recorded queries]({{< relref "../administration/recorded-queries" >}}) to see trends over time for your data sources. -- [Vault integration]({{< relref "../setup-grafana/configure-security/configure-database-encryption/encrypt-secrets-using-hashicorp-key-vault/" >}}) to manage your configuration or provisioning secrets with Vault. +- [Vault integration]({{< relref "../setup-grafana/configure-security/configure-database-encryption/#encrypting-your-database-with-a-key-from-a-key-management-service-kms" >}}) to manage your configuration or provisioning secrets with Vault. - [Auditing]({{< relref "../setup-grafana/configure-security/audit-grafana/" >}}) tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. - [Request security]({{< relref "../setup-grafana/configure-security/configure-request-security/" >}}) makes it possible to restrict outgoing requests from the Grafana server. - [Settings updates at runtime]({{< relref "../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}) allows you to update Grafana settings at runtime without requiring a restart. diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index 7c8e34ac2f8..627586663f4 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -55,9 +55,13 @@ Canvas introduces a new editing experience. You can now edit your canvas panel i Related to a fresh look at panel editing, the context menu enables you to perform common tasks quickly and efficiently. Supported functionality includes opening / closing the inline editor, duplicating an element, deleting an element, and more. -The context menu is triggered by a right click action over the panel / over a given canvas element +The context menu is triggered by a right click action over the panel / over a given canvas element. When right clicking the panel, you are able to set a background image and easily add elements to the canvas. -{{< figure src="/static/img/docs/canvas-panel/canvas-context-menu-9-2-0.png" max-width="750px" caption="Canvas context menu" >}} +{{< figure src="/static/img/docs/canvas-panel/canvas-panel-context-menu-9-3-0.png" max-width="750px" caption="Canvas panel context menu" >}} + +When right clicking an element, you are able to edit, delete, duplicate, and modify the element's layer positioning. + +{{< figure src="/static/img/docs/canvas-panel/canvas-context-menu-9-2-0.png" max-width="750px" caption="Canvas element context menu" >}} ## Canvas Options diff --git a/docs/sources/panels-visualizations/visualizations/geomap/index.md b/docs/sources/panels-visualizations/visualizations/geomap/index.md index 891311df08f..5118f1425e4 100644 --- a/docs/sources/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/panels-visualizations/visualizations/geomap/index.md @@ -81,12 +81,13 @@ There are three map layer types to choose from in the Geomap visualization. - [Heatmap]({{< relref "#heatmap-layer" >}}) visualizes a heatmap of the data. - [GeoJSON]({{< relref "#geojson-layer" >}}) renders static data from a GeoJSON file. -There are also four alpha layer types. +There are also five alpha layer types. - [Night / Day (alpha)]({{< relref "#night--day-layer-alpha" >}}) renders a night / day region. - **Icon at last point (alpha)** renders an icon at the last data point. - **Dynamic GeoJSON (alpha)** styles a GeoJSON file based on query results. - **Route (alpha)** render data points as a route. +- [Photos layer (Alpha)]({{< relref "#photos-layer-alpha" >}}) renders a photo at each data point. > **Note:** [Basemap layer types]({{< relref "#types-1" >}}) can also be added as layers. You can specify an opacity. @@ -351,6 +352,34 @@ The Night / Day layer displays night and day regions based on the current time r - [**Extensions for OpenLayers - DayNight**](https://viglino.github.io/ol-ext/examples/layer/map.daynight.html) +## Photos layer (Alpha) + +The Photos layer renders a photo at each data point. + +{{< figure src="/static/img/docs/geomap-panel/geomap-photos-9-3-0.png" max-width="1200px" caption="Geomap panel Photos" >}} + +### Options + +- **Image Source Field** select a string field containing image data in either of the following formats + - **Image URLs** + - **Base64 encoded** image binary ("data:image/png;base64,...") +- **Kind** select the frame style around the images + - **Square** + - **Circle** + - **Anchored** + - **Folio** +- **Crop** toggle if the images are cropped to fit +- **Shadow** toggle a box shadow behind the images +- **Border** set the border size around images +- **Border color** set the border color around images +- **Radius** set the overall size of images in pixels + +{{< figure src="/static/img/docs/geomap-panel/geomap-photos-options-9-3-0.png" max-width="1200px" caption="Geomap panel Photos options" >}} + +### More information + +- [**Extensions for OpenLayers - Image Photo Style**](http://viglino.github.io/ol-ext/examples/style/map.style.photo.html) + ## Map Controls The map controls section contains various options for map information and tool overlays. diff --git a/e2e/dashboards-suite/dashboard-search.spec.ts b/e2e/dashboards-suite/dashboard-search.spec.ts index 2ec8b6327f5..f2ec3136c6e 100644 --- a/e2e/dashboards-suite/dashboard-search.spec.ts +++ b/e2e/dashboards-suite/dashboard-search.spec.ts @@ -21,8 +21,5 @@ e2e.scenario({ e2e.components.Search.folderContent('General').should('be.visible'); e2e.components.Search.dashboardItem('E2E Test - Import Dashboard').should('be.visible'); - - // verify the imported dashboard is the only dashboard present in the folder - e2e().get('[data-testid^="data-testid Dashboard search item "]').should('have.length', 1); }, }); diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts index 6557a993c4c..96bf1230a86 100644 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ b/e2e/various-suite/loki-query-builder.spec.ts @@ -72,10 +72,6 @@ describe('Loki query builder', () => { e2e().contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist'); e2e().contains(finalQuery).should('be.visible'); - // Toggle raw query - e2e().contains('label', 'Raw query').click(); - e2e().contains('Raw query').should('have.length', 1); - // Change to code editor e2e().contains('label', 'Code').click(); // We need to test this manually because the final query is split into separate DOM elements using e2e().contains(finalQuery).should('be.visible'); does not detect the query. diff --git a/e2e/verify-release b/e2e/verify-release index 2bb9df037fa..304a68a6b53 100755 --- a/e2e/verify-release +++ b/e2e/verify-release @@ -2,4 +2,5 @@ . scripts/grafana-server/variables -./e2e/run-suite verify/specs +# The run-suite script requires a second argument to determine if videos should be recorded +./e2e/run-suite verify/specs true diff --git a/emails/Makefile b/emails/Makefile new file mode 100644 index 00000000000..e44bd04b9a1 --- /dev/null +++ b/emails/Makefile @@ -0,0 +1,14 @@ +build: build-html build-txt + +build-html: + npx mjml \ + --config.beautify true \ + --config.minify false \ + --config.validationLevel=strict \ + --config.keepComments=false \ + ./templates/*.mjml --output ../public/emails/ + +build-txt: + npx grunt + +.PHONY: build build-html build-txt diff --git a/emails/assets/css/ink.css b/emails/assets/css/ink.css deleted file mode 100644 index f4c1a291cb3..00000000000 --- a/emails/assets/css/ink.css +++ /dev/null @@ -1,688 +0,0 @@ -/********************************************** -* Ink v1.0.5 - Copyright 2013 ZURB Inc * -**********************************************/ - -/* Client-specific Styles & Reset */ - -#outlook a { - padding:0; -} - -body{ - width:100% !important; - min-width: 100%; - -webkit-text-size-adjust:100%; - -ms-text-size-adjust:100%; - margin:0; - padding:0; -} - - - -.ExternalClass { - width:100%; -} - -.ExternalClass, -.ExternalClass p, -.ExternalClass span, -.ExternalClass font, -.ExternalClass td, -.ExternalClass div { - line-height: 100%; -} - -#backgroundTable { - margin:0; - padding:0; - width:100% !important; - line-height: 100% !important; -} - -img { - outline:none; - text-decoration:none; - -ms-interpolation-mode: bicubic; - width: auto; - float: left; - clear: both; - display: block; -} - -center { - width: 100%; - min-width: 580px; -} - -a img { - border: none; -} - -p { - margin: 0 0 0 10px; -} - -table { - border-spacing: 0; - border-collapse: collapse; -} - -td { - word-break: break-word; - -webkit-hyphens: auto; - -moz-hyphens: auto; - hyphens: auto; - border-collapse: collapse !important; -} - -table, tr, td { - padding: 0; - vertical-align: top; - text-align: left; -} - -hr { - color: #d9d9d9; - background-color: #d9d9d9; - height: 1px; - border: none; -} - -/* Responsive Grid */ - -table.body { - height: 100%; - width: 100%; -} - -table.container { - width: 580px; - margin: 0 auto; - text-align: inherit; -} - -table.row { - padding: 0px; - width: 100%; - position: relative; -} - -table.container table.row { - display: block; -} - -td.wrapper { - padding: 10px 20px 0px 0px; - position: relative; -} - -table.columns, -table.column { - margin: 0 auto; -} - -table.columns td, -table.column td { - padding: 0px 0px 10px; -} - -table.columns td.sub-columns, -table.column td.sub-columns, -table.columns td.sub-column, -table.column td.sub-column { - padding-right: 10px; -} - -td.sub-column, td.sub-columns { - min-width: 0px; -} - -table.row td.last, -table.container td.last { - padding-right: 0px; -} - -table.one { width: 30px; } -table.two { width: 80px; } -table.three { width: 130px; } -table.four { width: 180px; } -table.five { width: 230px; } -table.six { width: 280px; } -table.seven { width: 330px; } -table.eight { width: 380px; } -table.nine { width: 430px; } -table.ten { width: 480px; } -table.eleven { width: 530px; } -table.twelve { width: 580px; } - -table.one center { min-width: 30px; } -table.two center { min-width: 80px; } -table.three center { min-width: 130px; } -table.four center { min-width: 180px; } -table.five center { min-width: 230px; } -table.six center { min-width: 280px; } -table.seven center { min-width: 330px; } -table.eight center { min-width: 380px; } -table.nine center { min-width: 430px; } -table.ten center { min-width: 480px; } -table.eleven center { min-width: 530px; } -table.twelve center { min-width: 580px; } - -table.one .panel center { min-width: 10px; } -table.two .panel center { min-width: 60px; } -table.three .panel center { min-width: 110px; } -table.four .panel center { min-width: 160px; } -table.five .panel center { min-width: 210px; } -table.six .panel center { min-width: 260px; } -table.seven .panel center { min-width: 310px; } -table.eight .panel center { min-width: 360px; } -table.nine .panel center { min-width: 410px; } -table.ten .panel center { min-width: 460px; } -table.eleven .panel center { min-width: 510px; } -table.twelve .panel center { min-width: 560px; } - -.body .columns td.one, -.body .column td.one { width: 8.333333%; } -.body .columns td.two, -.body .column td.two { width: 16.666666%; } -.body .columns td.three, -.body .column td.three { width: 25%; } -.body .columns td.four, -.body .column td.four { width: 33.333333%; } -.body .columns td.five, -.body .column td.five { width: 41.666666%; } -.body .columns td.six, -.body .column td.six { width: 50%; } -.body .columns td.seven, -.body .column td.seven { width: 58.333333%; } -.body .columns td.eight, -.body .column td.eight { width: 66.666666%; } -.body .columns td.nine, -.body .column td.nine { width: 75%; } -.body .columns td.ten, -.body .column td.ten { width: 83.333333%; } -.body .columns td.eleven, -.body .column td.eleven { width: 91.666666%; } -.body .columns td.twelve, -.body .column td.twelve { width: 100%; } - -td.offset-by-one { padding-left: 50px; } -td.offset-by-two { padding-left: 100px; } -td.offset-by-three { padding-left: 150px; } -td.offset-by-four { padding-left: 200px; } -td.offset-by-five { padding-left: 250px; } -td.offset-by-six { padding-left: 300px; } -td.offset-by-seven { padding-left: 350px; } -td.offset-by-eight { padding-left: 400px; } -td.offset-by-nine { padding-left: 450px; } -td.offset-by-ten { padding-left: 500px; } -td.offset-by-eleven { padding-left: 550px; } - -td.expander { - visibility: hidden; - width: 0px; - padding: 0 !important; -} - -table.columns .text-pad, -table.column .text-pad { - padding-left: 10px; - padding-right: 10px; -} - -table.columns .left-text-pad, -table.columns .text-pad-left, -table.column .left-text-pad, -table.column .text-pad-left { - padding-left: 10px; -} - -table.columns .right-text-pad, -table.columns .text-pad-right, -table.column .right-text-pad, -table.column .text-pad-right { - padding-right: 10px; -} - -/* Block Grid */ - -.block-grid { - width: 100%; - max-width: 580px; -} - -.block-grid td { - display: inline-block; - padding:10px; -} - -.two-up td { - width:270px; -} - -.three-up td { - width:173px; -} - -.four-up td { - width:125px; -} - -.five-up td { - width:96px; -} - -.six-up td { - width:76px; -} - -.seven-up td { - width:62px; -} - -.eight-up td { - width:52px; -} - -/* Alignment & Visibility Classes */ - -table.center, td.center { - text-align: center; -} - -h1.center, -h2.center, -h3.center, -h4.center, -h5.center, -h6.center { - text-align: center; -} - -span.center { - display: block; - width: 100%; - text-align: center; -} - -img.center { - margin: 0 auto; - float: none; -} - -.show-for-small, -.hide-for-desktop { - display: none; -} - -/* Typography */ - -body, table.body, h1, h2, h3, h4, h5, h6, p, td { - color: #222222; - font-family: "Helvetica", "Arial", sans-serif; - font-weight: normal; - padding:0; - margin: 0; - text-align: left; - line-height: 1.3; -} - -h1, h2, h3, h4, h5, h6 { - word-break: normal; -} - -h1 {font-size: 40px;} -h2 {font-size: 36px;} -h3 {font-size: 32px;} -h4 {font-size: 28px;} -h5 {font-size: 24px;} -h6 {font-size: 20px;} -body, table.body, p, td {font-size: 14px;line-height:19px;} - -p.lead, p.lede, p.leed { - font-size: 18px; - line-height:21px; -} - -p { - margin-bottom: 10px; -} - -small { - font-size: 10px; -} - -a { - color: #2ba6cb; - text-decoration: none; -} - -a:hover { - color: #2795b6 !important; -} - -a:active { - color: #2795b6 !important; -} - -a:visited { - color: #2ba6cb !important; -} - -h1 a, -h2 a, -h3 a, -h4 a, -h5 a, -h6 a { - color: #2ba6cb; -} - -h1 a:active, -h2 a:active, -h3 a:active, -h4 a:active, -h5 a:active, -h6 a:active { - color: #2ba6cb !important; -} - -h1 a:visited, -h2 a:visited, -h3 a:visited, -h4 a:visited, -h5 a:visited, -h6 a:visited { - color: #2ba6cb !important; -} - -/* Panels */ - -.panel { - background: #f2f2f2; - border: 1px solid #d9d9d9; - padding: 10px !important; -} - -.sub-grid table { - width: 100%; -} - -.sub-grid td.sub-columns { - padding-bottom: 0; -} - -/* Buttons */ - -table.button, -table.tiny-button, -table.small-button, -table.medium-button, -table.large-button { - width: 100%; - overflow: hidden; -} - -table.button td, -table.tiny-button td, -table.small-button td, -table.medium-button td, -table.large-button td { - display: block; - width: auto !important; - text-align: center; - background: #2ba6cb; - border: 1px solid #2284a1; - color: #ffffff; - padding: 8px 0; -} - -table.tiny-button td { - padding: 5px 0 4px; -} - -table.small-button td { - padding: 8px 0 7px; -} - -table.medium-button td { - padding: 12px 0 10px; -} - -table.large-button td { - padding: 21px 0 18px; -} - -table.button td a, -table.tiny-button td a, -table.small-button td a, -table.medium-button td a, -table.large-button td a { - font-weight: bold; - text-decoration: none; - font-family: Helvetica, Arial, sans-serif; - color: #ffffff; - font-size: 16px; -} - -table.tiny-button td a { - font-size: 12px; - font-weight: normal; -} - -table.small-button td a { - font-size: 16px; -} - -table.medium-button td a { - font-size: 20px; -} - -table.large-button td a { - font-size: 24px; -} - -table.button:hover td, -table.button:visited td, -table.button:active td { - background: #2795b6 !important; -} - -table.button:hover td a, -table.button:visited td a, -table.button:active td a { - color: #fff !important; -} - -table.button:hover td, -table.tiny-button:hover td, -table.small-button:hover td, -table.medium-button:hover td, -table.large-button:hover td { - background: #2795b6 !important; -} - -table.button:hover td a, -table.button:active td a, -table.button td a:visited, -table.tiny-button:hover td a, -table.tiny-button:active td a, -table.tiny-button td a:visited, -table.small-button:hover td a, -table.small-button:active td a, -table.small-button td a:visited, -table.medium-button:hover td a, -table.medium-button:active td a, -table.medium-button td a:visited, -table.large-button:hover td a, -table.large-button:active td a, -table.large-button td a:visited { - color: #ffffff !important; -} - -table.secondary td { - background: #e9e9e9; - border-color: #d0d0d0; - color: #555; -} - -table.secondary td a { - color: #555; -} - -table.secondary:hover td { - background: #d0d0d0 !important; - color: #555; -} - -table.secondary:hover td a, -table.secondary td a:visited, -table.secondary:active td a { - color: #555 !important; -} - -table.success td { - background: #5da423; - border-color: #457a1a; -} - -table.success:hover td { - background: #457a1a !important; -} - -table.alert td { - background: #c60f13; - border-color: #970b0e; -} - -table.alert:hover td { - background: #970b0e !important; -} - -table.radius td { - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -table.round td { - -webkit-border-radius: 500px; - -moz-border-radius: 500px; - border-radius: 500px; -} - -/* Outlook First */ - -body.outlook p { - display: inline !important; -} - -/* Media Queries */ - -@media only screen and (max-width: 600px) { - - table[class="body"] img { - - } - - table[class="body"] center { - min-width: 0 !important; - } - - table[class="body"] .container { - width: 95% !important; - } - - table[class="body"] .row { - width: 100% !important; - display: block !important; - } - - table[class="body"] .wrapper { - display: block !important; - padding-right: 0 !important; - } - - table[class="body"] .columns, - table[class="body"] .column { - table-layout: fixed !important; - float: none !important; - width: 100% !important; - padding-right: 0px !important; - padding-left: 0px !important; - display: block !important; - } - - table[class="body"] .wrapper.first .columns, - table[class="body"] .wrapper.first .column { - display: table !important; - } - - table[class="body"] table.columns td, - table[class="body"] table.column td { - width: 100% !important; - } - - table[class="body"] .columns td.one, - table[class="body"] .column td.one { width: 8.333333% !important; } - table[class="body"] .columns td.two, - table[class="body"] .column td.two { width: 16.666666% !important; } - table[class="body"] .columns td.three, - table[class="body"] .column td.three { width: 25% !important; } - table[class="body"] .columns td.four, - table[class="body"] .column td.four { width: 33.333333% !important; } - table[class="body"] .columns td.five, - table[class="body"] .column td.five { width: 41.666666% !important; } - table[class="body"] .columns td.six, - table[class="body"] .column td.six { width: 50% !important; } - table[class="body"] .columns td.seven, - table[class="body"] .column td.seven { width: 58.333333% !important; } - table[class="body"] .columns td.eight, - table[class="body"] .column td.eight { width: 66.666666% !important; } - table[class="body"] .columns td.nine, - table[class="body"] .column td.nine { width: 75% !important; } - table[class="body"] .columns td.ten, - table[class="body"] .column td.ten { width: 83.333333% !important; } - table[class="body"] .columns td.eleven, - table[class="body"] .column td.eleven { width: 91.666666% !important; } - table[class="body"] .columns td.twelve, - table[class="body"] .column td.twelve { width: 100% !important; } - - table[class="body"] td.offset-by-one, - table[class="body"] td.offset-by-two, - table[class="body"] td.offset-by-three, - table[class="body"] td.offset-by-four, - table[class="body"] td.offset-by-five, - table[class="body"] td.offset-by-six, - table[class="body"] td.offset-by-seven, - table[class="body"] td.offset-by-eight, - table[class="body"] td.offset-by-nine, - table[class="body"] td.offset-by-ten, - table[class="body"] td.offset-by-eleven { - padding-left: 0 !important; - } - - table[class="body"] table.columns td.expander { - width: 1px !important; - } - - table[class="body"] .right-text-pad, - table[class="body"] .text-pad-right { - padding-left: 10px !important; - } - - table[class="body"] .left-text-pad, - table[class="body"] .text-pad-left { - padding-right: 10px !important; - } - - table[class="body"] .hide-for-small, - table[class="body"] .show-for-desktop { - display: none !important; - } - - table[class="body"] .show-for-small, - table[class="body"] .hide-for-desktop { - display: inherit !important; - } -} diff --git a/emails/assets/css/style.css b/emails/assets/css/style.css deleted file mode 100644 index 065421a173d..00000000000 --- a/emails/assets/css/style.css +++ /dev/null @@ -1,196 +0,0 @@ - -body, table.body, h1, h2, h3, h4, h5, h6, p, td { - font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -webkit-text-size-adjust: none; -} - -h1 {font-size: 40px;} -h2 {font-size: 36px;} -h3 { - font-size: 22px; - margin-top: 10px; - margin-bottom: 10px; -} -h4 {font-size: 20px;} -h5 {font-size: 18px;} -h6 {font-size: 16px;} - -.emphasis { - font-weight: 600; -} - -a { - color: #E67612; - text-decoration: none; -} - -a:hover { - color: #ff8f2b !important; -} - -a:active { - color: #F2821E !important; -} - -a:visited { - color: #E67612 !important; -} - -table.facebook td { - background: #3b5998; - border-color: #2d4473; -} - -table.facebook:hover td { - background: #2d4473 !important; -} - -table.twitter td { - background: #00acee; - border-color: #0087bb; -} - -table.twitter:hover td { - background: #0087bb !important; -} - -table.google-plus td { - background-color: #DB4A39; - border-color: #CC0000; -} - -table.google-plus:hover td { - background: #CC0000 !important; -} - -.template-label { - color: #ffffff; - font-weight: bold; - font-size: 11px; -} - -.callout .wrapper { - padding-bottom: 20px; -} - -.callout .panel { - background: #ECF8FF; - border-color: #b9e5ff; -} - -.header { -margin-top:25px; -margin-bottom: 25px; -} - -.data { - font-size: 16px; -} - -.footer { - background-color: #2e2e2e; - color: #999999; - margin: 0 auto; - width: 100%; -} - -@media only screen and (max-width: 600px) { - table[class="body"] .right-text-pad { - padding-left: 10px !important; - } - - table[class="body"] .left-text-pad { - padding-right: 10px !important; - } - - .logo { - margin-left: 10px; - } -} - -table.better-button { - margin-top: 10px; - margin-bottom: 20px; -} - -table.columns td.better-button { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - padding-bottom: 0px; -} - -.better-button a { - text-decoration: none; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - - padding: 12px 25px; - border: 1px solid #ff8f2b; - display: inline-block; - color: #FFF; -} - -.better-button:hover a { - color: #FFFFFF !important; - background-color: #F2821E; - border: 1px solid #F2821E; -} - -.better-button:visited a { - color: #FFFFFF !important; -} - -.better-button:active a { - color: #FFFFFF !important; -} - -table.better-button-alt { - margin-top: 10px; - margin-bottom: 20px; -} - -table.columns td.better-button-alt { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - padding-bottom: 0px; -} - -.better-button-alt a { - text-decoration: none; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - - padding: 12px 25px; - border: 1px solid #ff8f2b; - background-color: #EFEFEF; - display: inline-block; - color: #ff8f2b; -} - -.better-button-alt:hover a { - color: #ff8f2b !important; - background-color: #DDDDDD; - border: 1px solid #F2821E; -} - -.better-button-alt:visited a { - color: #ff8f2b !important; -} - -.better-button-alt:active a { - color: #ff8f2b !important; -} - -.verification-code { - background-color: #EEEEEE; - padding: 3px; - margin: 8px; - display: inline-block; - font-weight: bold; - font-size: 20px; -} diff --git a/emails/grunt/aliases.yaml b/emails/grunt/aliases.yaml index 6a2e47777cd..550d7a5422a 100644 --- a/emails/grunt/aliases.yaml +++ b/emails/grunt/aliases.yaml @@ -1,8 +1,5 @@ - default: - - 'clean' - - 'assemble' - - 'replace' - - 'uncss' - - 'processhtml' - - 'premailer' + - 'clean' + - 'assemble' + - 'replace' + - 'copy' diff --git a/emails/grunt/assemble.js b/emails/grunt/assemble.js index 6ef46860267..e0d1f103a42 100644 --- a/emails/grunt/assemble.js +++ b/emails/grunt/assemble.js @@ -2,21 +2,12 @@ module.exports = function () { 'use strict'; return { options: { - partials: ['templates/partials/*.hbs'], - helpers: ['templates/helpers/**/*.js'], data: [], flatten: true, }, - html: { - options: { - layout: 'templates/layouts/default.html', - }, - src: ['templates/*.html'], - dest: 'dist/', - }, txt: { options: { - layout: 'templates/layouts/default.txt', + layout: 'templates/partials/layout/default.txt', ext: '.txt', }, src: ['templates/*.txt'], diff --git a/emails/grunt/copy.js b/emails/grunt/copy.js new file mode 100644 index 00000000000..4f794d85a3a --- /dev/null +++ b/emails/grunt/copy.js @@ -0,0 +1,11 @@ +module.exports = function () { + 'use strict'; + return { + txt: { + expand: true, + cwd: 'dist', + src: ['**.txt'], + dest: '../public/emails/', + }, + }; +}; diff --git a/emails/grunt/premailer.js b/emails/grunt/premailer.js deleted file mode 100644 index f587095c256..00000000000 --- a/emails/grunt/premailer.js +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = { - html: { - options: { - verbose: true, - removeComments: true, - }, - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.html'], // Actual pattern(s) to match. - dest: '../public/emails/', // Destination path prefix. - }, - ], - }, - txt: { - options: { - verbose: true, - mode: 'txt', - lineLength: 90, - }, - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.txt'], // Actual patterns to match. - dest: '../public/emails/', // Destination path prefix. - }, - ], - }, -}; diff --git a/emails/grunt/processhtml.js b/emails/grunt/processhtml.js deleted file mode 100644 index 777b2d27d73..00000000000 --- a/emails/grunt/processhtml.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - dist: { - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.html'], // Actual pattern(s) to match. - dest: 'dist/', // Destination path prefix. - }, - ], - }, -}; diff --git a/emails/grunt/replace.js b/emails/grunt/replace.js index 0d8c030d2f3..be4f18e34d4 100644 --- a/emails/grunt/replace.js +++ b/emails/grunt/replace.js @@ -1,7 +1,7 @@ module.exports = { dist: { overwrite: true, - src: ['dist/*.html', 'dist/*.txt'], + src: ['dist/*.txt'], replacements: [ { from: '[[', diff --git a/emails/grunt/uncss.js b/emails/grunt/uncss.js deleted file mode 100644 index c1ec535e1bb..00000000000 --- a/emails/grunt/uncss.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - dist: { - src: ['dist/*.html'], - dest: 'dist/css/tidy.css', - options: { - report: 'min', // optional: include to report savings - }, - }, -}; diff --git a/emails/grunt/watch.js b/emails/grunt/watch.js deleted file mode 100644 index b071320b3e6..00000000000 --- a/emails/grunt/watch.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - src: { - files: [ - //what are the files that we want to watch - 'assets/css/*.css', - 'templates/**/*.html', - 'templates/**/*.txt', - 'grunt/*.js', - ], - tasks: ['default'], - options: { - nospawn: true, - livereload: false, - }, - }, -}; diff --git a/emails/package.json b/emails/package.json index f4eb62d4eae..c8de033b507 100644 --- a/emails/package.json +++ b/emails/package.json @@ -1,26 +1,17 @@ { - "name": "Grafana-Email-Campaign", + "name": "grafana-email-campaign", "version": "1.0.0", - "description": "Grafana Email templates based on Zurb Ink", - "repository": "dnnsldr/", - "author": { - "name": "dnnsldr", - "email": "delder@riester.com", - "url": "https://github.com/dnnsldr" - }, - "scripts": { - "build": "grunt", - "start": "grunt watch" - }, + "description": "Grafana Email templates based on MJML", + "author": "Grafana Labs", "devDependencies": { "grunt": "1.0.1", - "grunt-premailer": "1.1.0", - "grunt-processhtml": "^0.4.2", - "grunt-uncss": "0.9.0", - "load-grunt-config": "3.0.1", + "grunt-assemble": "0.6.3", + "grunt-cli": "^1.4.3", + "grunt-contrib-clean": "2.0.0", + "grunt-contrib-copy": "^1.0.0", "grunt-contrib-watch": "1.1.0", "grunt-text-replace": "0.4.0", - "grunt-assemble": "0.6.3", - "grunt-contrib-clean": "2.0.0" + "load-grunt-config": "3.0.1", + "mjml": "^4.13.0" } } diff --git a/emails/templates/alert_notification.html b/emails/templates/alert_notification.html deleted file mode 100644 index 98d2f7ff56c..00000000000 --- a/emails/templates/alert_notification.html +++ /dev/null @@ -1,134 +0,0 @@ -[[Subject .Subject "[[.Title]]"]] - - - - - -
- - - - -
-

[[.Title]]

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

[[.Message]]

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

[[.Error]]

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

You have been added to [[.OrgName]]

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

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

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

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

You have been added to {{ .OrgName }}

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

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

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

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

-
- - - - -
Accept Invitation
-
-

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

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

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

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

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

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

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

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

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

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

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

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

+ 📁 Grouped by  +

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

Hi [[.Name]],

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

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

-

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

-

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

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

Hi {{ .Name }},

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

Complete the signup

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

Complete the signup

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

Hi [[.Name]],

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

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

-
- Thank you for joining our community. -
-

The Grafana Team

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

Hi {{ .Name }},

+
+ + Welcome! Ready to start building some beautiful metric and analytic dashboards? + + + If you are new to Grafana, refer to the Getting started with Grafana + guide. + + + Check out our getting started guide + + + Thank you for joining our community. + + + The Grafana Team + +
+
+ + + +
+
diff --git a/go.mod b/go.mod index 1e5d862639d..8f29add2ffd 100644 --- a/go.mod +++ b/go.mod @@ -256,9 +256,7 @@ require ( github.com/grafana/codejen v0.0.3 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f github.com/jmoiron/sqlx v1.3.5 - github.com/kr/pretty v0.3.0 github.com/matryer/is v1.4.0 - github.com/parca-dev/parca v0.12.1 github.com/urfave/cli v1.22.9 go.etcd.io/etcd/api/v3 v3.5.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.32.0 @@ -271,11 +269,16 @@ require ( require ( cloud.google.com/go v0.102.0 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/armon/go-metrics v0.3.10 // indirect github.com/bmatcuk/doublestar v1.1.1 // indirect github.com/buildkite/yaml v2.1.0+incompatible // indirect + github.com/containerd/containerd v1.6.8 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/distribution v2.8.1+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect github.com/drone-runners/drone-runner-docker v1.8.2 // indirect github.com/drone/drone-go v1.7.1 // indirect github.com/drone/envsubst v1.0.3 // indirect @@ -287,15 +290,22 @@ require ( github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/memberlist v0.4.0 // indirect + github.com/huandu/xstrings v1.3.1 // indirect github.com/invopop/yaml v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect + github.com/parca-dev/parca v0.12.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.1.4 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect go.starlark.net v0.0.0-20221020143700-22309ac47eac // indirect ) @@ -305,6 +315,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.2.2 github.com/Microsoft/go-winio v0.5.2 // indirect github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/RoaringBitmap/roaring v0.9.4 // indirect @@ -321,6 +332,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/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 88e4e481756..d41b9baea0d 100644 --- a/go.sum +++ b/go.sum @@ -234,11 +234,16 @@ github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnl github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/squirrel v0.0.0-20161115235646-20f192218cf5/go.mod h1:xnKTFzjGUiZtiOagBsfnvomW+nJg2usB1ZpordQWqNM= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= @@ -574,6 +579,7 @@ github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoT github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= github.com/containerd/containerd v1.5.4/go.mod h1:sx18RgvW6ABJ4iYUw7Q5x7bgFOAB9B6G7+yO0XBc4zw= 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= @@ -746,6 +752,7 @@ github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= +github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= @@ -1507,6 +1514,8 @@ github.com/hetznercloud/hcloud-go v1.33.2 h1:ptWKVYLW7YtjXzsqTFKFxwpVo3iM9UMkVPB github.com/hodgesds/perf-utils v0.0.8/go.mod h1:F6TfvsbtrF88i++hou29dTXlI2sfsJv+gRZDtmTJkAs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= +github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -1865,6 +1874,7 @@ github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -2003,6 +2013,7 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 h1:rc3tiVYb5z54aKaDfakKn0dDjIyPpTtszkjuMzyt7ec= +github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= @@ -2265,6 +2276,7 @@ github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.6+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= @@ -2305,6 +2317,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52 github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -2667,6 +2680,7 @@ golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -3472,6 +3486,7 @@ k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7/go.mod h1:wXW5VT87nVfh/iLV8FpR2uDvrFyomxbtb1KivDbvPTE= +k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e h1:KLHHjkdQFomZy8+06csTWZ0m1343QqxZhR2LJ1OxCYM= k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 h1:Gii5eqf+GmIEwGNKQYQClCayuJCe2/4fZUvF7VG99sU= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= diff --git a/jest.config.js b/jest.config.js index 9d0047b98e3..f9e545b4886 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ module.exports = { verbose: false, testEnvironment: 'jsdom', transform: { - '^.+\\.(ts|tsx|js|jsx)$': require.resolve('ts-jest'), + '^.+\\.(ts|tsx|js|jsx)$': [require.resolve('ts-jest'), { isolatedModules: true }], }, transformIgnorePatterns: [ 'node_modules/(?!(ol)/)', // <- exclude the open layers library @@ -22,7 +22,6 @@ module.exports = { setupFilesAfterEnv: ['./public/test/setupTests.ts'], snapshotSerializers: ['enzyme-to-json/serializer'], globals: { - 'ts-jest': { isolatedModules: true }, __webpack_public_path__: '', // empty string }, moduleNameMapper: { diff --git a/kinds/gen.go b/kinds/gen.go index 41f0d527049..f9981203e17 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -21,8 +21,6 @@ import ( "github.com/grafana/grafana/pkg/kindsys" ) -const sep = string(filepath.Separator) - func main() { if len(os.Args) > 1 { fmt.Fprintf(os.Stderr, "plugin thema code generator does not currently accept any arguments\n, got %q", os.Args) @@ -37,16 +35,11 @@ func main() { // All the jennies that comprise the core kinds generator pipeline coreKindsGen.Append( - codegen.GoTypesJenny(kindsys.GoCoreKindParentPath, nil), + codegen.LatestJenny(kindsys.GoCoreKindParentPath, codegen.GoTypesJenny{}), codegen.CoreStructuredKindJenny(kindsys.GoCoreKindParentPath, nil), codegen.RawKindJenny(kindsys.GoCoreKindParentPath, nil), codegen.BaseCoreRegistryJenny(filepath.Join("pkg", "registry", "corekind"), kindsys.GoCoreKindParentPath), - codegen.TSTypesJenny(kindsys.TSCoreKindParentPath, &codegen.TSTypesGeneratorConfig{ - GenDirName: func(decl *codegen.DeclForGen) string { - // FIXME this hardcodes always generating to experimental dir. OK for now, but need generator fanout - return filepath.Join(decl.Meta.Common().MachineName, "x") - }, - }), + codegen.LatestMajorsOrXJenny(kindsys.TSCoreKindParentPath, codegen.TSTypesJenny{}), codegen.TSVeneerIndexJenny(filepath.Join("packages", "grafana-schema", "src")), ) diff --git a/latest.json b/latest.json index 17efb856b65..5d0d253a775 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.2.5", + "stable": "9.2.6", "testing": "9.3.0-beta1" } diff --git a/package.json b/package.json index 0628461291e..c92cedaa789 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@types/gtag.js": "^0.0.12", "@types/history": "4.7.11", "@types/hoist-non-react-statics": "3.3.1", - "@types/jest": "28.1.6", + "@types/jest": "29.2.3", "@types/jquery": "3.5.14", "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", @@ -165,7 +165,7 @@ "@typescript-eslint/parser": "5.42.0", "@wojtekmaj/enzyme-adapter-react-17": "0.7.0", "autoprefixer": "10.4.13", - "babel-jest": "28.1.3", + "babel-jest": "29.3.1", "babel-loader": "9.1.0", "babel-plugin-angularjs-annotate": "0.10.0", "babel-plugin-macros": "3.1.0", @@ -195,13 +195,13 @@ "http-server": "14.1.1", "husky": "8.0.1", "i18next-parser": "6.6.0", - "jest": "28.1.3", + "jest": "29.3.1", "jest-canvas-mock": "2.4.0", "jest-date-mock": "1.0.8", - "jest-environment-jsdom": "28.1.3", + "jest-environment-jsdom": "29.3.1", "jest-fail-on-console": "3.0.2", "jest-junit": "14.0.1", - "jest-matcher-utils": "28.1.3", + "jest-matcher-utils": "29.3.1", "lerna": "5.5.4", "lint-staged": "13.0.3", "mini-css-extract-plugin": "2.6.1", @@ -230,7 +230,7 @@ "stylelint-config-sass-guidelines": "9.0.1", "terser-webpack-plugin": "5.3.6", "testing-library-selector": "0.2.1", - "ts-jest": "28.0.7", + "ts-jest": "29.0.3", "ts-loader": "9.3.1", "ts-node": "10.9.1", "typescript": "4.8.4", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 863a05c0335..537beb5f0af 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -66,7 +66,7 @@ "@testing-library/react-hooks": "8.0.1", "@testing-library/user-event": "14.4.3", "@types/history": "4.7.11", - "@types/jest": "28.1.6", + "@types/jest": "29.2.3", "@types/jquery": "3.5.14", "@types/lodash": "4.14.187", "@types/marked": "4.0.7", diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts index 20001cb4913..c0980c39481 100644 --- a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts @@ -40,14 +40,14 @@ describe('Array DataFrame', () => { test('Should convert an array of objects to a dataframe', () => { expect(toDataFrameDTO(frame)).toMatchInlineSnapshot(` - Object { - "fields": Array [ - Object { - "config": Object {}, + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "name", "type": "string", - "values": Array [ + "values": [ "first", "second", "third", @@ -55,14 +55,14 @@ describe('Array DataFrame', () => { "5th (Null)", ], }, - Object { - "config": Object { + { + "config": { "unit": "kwh", }, "labels": undefined, "name": "value", "type": "number", - "values": Array [ + "values": [ 1, 2, 3, @@ -70,12 +70,12 @@ describe('Array DataFrame', () => { null, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "time", "type": "time", - "values": Array [ + "values": [ 123, 456, 789, @@ -83,12 +83,12 @@ describe('Array DataFrame', () => { 1100, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "phantom", "type": "string", - "values": Array [ + "values": [ "🦥", "🦥", "🦥", diff --git a/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts b/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts index 8a32d377c4e..6cd11677ba0 100644 --- a/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts +++ b/packages/grafana-data/src/dataframe/DataFrameJSON.test.ts @@ -29,50 +29,50 @@ describe('DataFrame JSON', () => { const frame = dataFrameFromJSON(json); expect(frame).toMatchInlineSnapshot(` - Object { - "fields": Array [ - Object { - "config": Object {}, - "entities": Object {}, + { + "fields": [ + { + "config": {}, + "entities": {}, "name": "time", "type": "time", - "values": Array [ + "values": [ 100, 200, 300, ], }, - Object { - "config": Object {}, - "entities": Object { - "Inf": Array [ + { + "config": {}, + "entities": { + "Inf": [ 1, ], - "NaN": Array [ + "NaN": [ 0, ], - "Undef": Array [ + "Undef": [ 2, ], }, "name": "name", "type": "string", - "values": Array [ + "values": [ NaN, Infinity, undefined, ], }, - Object { - "config": Object {}, - "entities": Object { - "NegInf": Array [ + { + "config": {}, + "entities": { + "NegInf": [ 2, ], }, "name": "value", "type": "number", - "values": Array [ + "values": [ 1, 2, -Infinity, @@ -106,26 +106,26 @@ describe('DataFrame JSON', () => { const frame = dataFrameFromJSON(json); expect(frame).toMatchInlineSnapshot(` - Object { - "fields": Array [ - Object { - "config": Object {}, - "entities": Object {}, + { + "fields": [ + { + "config": {}, + "entities": {}, "name": "time", "type": "time", - "values": Array [ + "values": [ 100, 200, 300, 400, ], }, - Object { - "config": Object {}, - "entities": Object {}, + { + "config": {}, + "entities": {}, "name": "value", "type": "string", - "values": Array [ + "values": [ "bar", "foo", "baz", diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts index 0683697263e..ad08566a507 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -199,16 +199,16 @@ describe('toDataFrame', () => { }; const dataFrame = toDataFrame(msg); expect(dataFrame.fields.map((f) => ({ [f.name]: f.values.toArray() }))).toMatchInlineSnapshot(` - Array [ - Object { - "First": Array [ + [ + { + "First": [ "2019-02-15", "2019-03-15", "2019-04-15", ], }, - Object { - "Second": Array [ + { + "Second": [ 3, 9, 16, diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 7b5d8ee5cd7..07a69a8bd58 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -186,29 +186,29 @@ describe('applyFieldOverrides', () => { }); expect(withOverrides[0].fields[0].state!.scopedVars).toMatchInlineSnapshot(` - Object { - "__field": Object { - "text": "Field", - "value": Object {}, - }, - "__series": Object { - "text": "Series", - "value": Object { - "name": "A", - }, - }, - } - `); + { + "__field": { + "text": "Field", + "value": {}, + }, + "__series": { + "text": "Series", + "value": { + "name": "A", + }, + }, + } + `); expect(withOverrides[1].fields[0].state!.scopedVars).toMatchInlineSnapshot(` - Object { - "__field": Object { + { + "__field": { "text": "Field", - "value": Object {}, + "value": {}, }, - "__series": Object { + "__series": { "text": "Series", - "value": Object { + "value": { "name": "B", }, }, @@ -384,8 +384,8 @@ describe('setFieldConfigDefaults', () => { setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); expect(dsFieldConfig).toMatchInlineSnapshot(` - Object { - "custom": Object {}, + { + "custom": {}, "decimals": 2, "max": 100, "min": 0, @@ -419,8 +419,8 @@ describe('setFieldConfigDefaults', () => { setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); expect(dsFieldConfig).toMatchInlineSnapshot(` - Object { - "custom": Object { + { + "custom": { "property1": 10, "property2": 10, }, diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts index 0ebf4030b33..aba72a8585d 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts @@ -208,12 +208,12 @@ describe('calculateField transformer w/ timeseries', () => { const filtered = data[0]; const rows = new DataFrameView(filtered).toArray(); expect(rows).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "E * 1": 1, "TheTime": 1000, }, - Object { + { "E * 1": 0, "TheTime": 2000, }, @@ -267,12 +267,12 @@ describe('calculateField transformer w/ timeseries', () => { const filtered = data[0]; const rows = new DataFrameView(filtered).toArray(); expect(rows).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "Test": 6, "TheTime": 1000, }, - Object { + { "Test": 105, "TheTime": 2000, }, diff --git a/packages/grafana-data/src/transformations/transformers/concat.test.ts b/packages/grafana-data/src/transformations/transformers/concat.test.ts index 20d5b9ebaa9..20a79781929 100644 --- a/packages/grafana-data/src/transformations/transformers/concat.test.ts +++ b/packages/grafana-data/src/transformations/transformers/concat.test.ts @@ -25,28 +25,28 @@ describe('Concat Transformer', () => { const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.Drop }); expect(frame.length).toBe(3); expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "labels": undefined, "name": "A", }, - Object { + { "labels": undefined, "name": "B", }, - Object { + { "labels": undefined, "name": "C", }, - Object { + { "labels": undefined, "name": "X", }, - Object { + { "labels": undefined, "name": "Y", }, - Object { + { "labels": undefined, "name": "Z", }, @@ -58,28 +58,28 @@ describe('Concat Transformer', () => { const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.FieldName }); expect(frame.length).toBe(3); expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "labels": undefined, "name": "ABC · A", }, - Object { + { "labels": undefined, "name": "ABC · B", }, - Object { + { "labels": undefined, "name": "ABC · C", }, - Object { + { "labels": undefined, "name": "XYZ · X", }, - Object { + { "labels": undefined, "name": "XYZ · Y", }, - Object { + { "labels": undefined, "name": "XYZ · Z", }, @@ -94,39 +94,39 @@ describe('Concat Transformer', () => { }); expect(frame.length).toBe(3); expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` - Array [ - Object { - "labels": Object { + [ + { + "labels": { "sensor": "ABC", }, "name": "A", }, - Object { - "labels": Object { + { + "labels": { "sensor": "ABC", }, "name": "B", }, - Object { - "labels": Object { + { + "labels": { "sensor": "ABC", }, "name": "C", }, - Object { - "labels": Object { + { + "labels": { "sensor": "XYZ", }, "name": "X", }, - Object { - "labels": Object { + { + "labels": { "sensor": "XYZ", }, "name": "Y", }, - Object { - "labels": Object { + { + "labels": { "sensor": "XYZ", }, "name": "Z", diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts index b67bb998870..ae2b231b680 100644 --- a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts @@ -51,66 +51,66 @@ describe('ensureColumns transformer', () => { const frame = filtered[0]; expect(frame.fields.length).toEqual(5); expect(filtered[0]).toMatchInlineSnapshot(` - Object { - "fields": Array [ - Object { - "config": Object {}, + { + "fields": [ + { + "config": {}, "name": "TheTime", - "state": Object {}, + "state": {}, "type": "time", - "values": Array [ + "values": [ 1000, 2000, ], }, - Object { - "config": Object {}, - "labels": Object {}, + { + "config": {}, + "labels": {}, "name": "A", - "state": Object {}, + "state": {}, "type": "number", - "values": Array [ + "values": [ 1, 100, ], }, - Object { - "config": Object {}, - "labels": Object {}, + { + "config": {}, + "labels": {}, "name": "B", - "state": Object {}, + "state": {}, "type": "number", - "values": Array [ + "values": [ 2, 200, ], }, - Object { - "config": Object {}, - "labels": Object {}, + { + "config": {}, + "labels": {}, "name": "C", - "state": Object {}, + "state": {}, "type": "number", - "values": Array [ + "values": [ 3, 300, ], }, - Object { - "config": Object {}, - "labels": Object {}, + { + "config": {}, + "labels": {}, "name": "D", - "state": Object {}, + "state": {}, "type": "string", - "values": Array [ + "values": [ "first", "second", ], }, ], "length": 2, - "meta": Object { - "transformations": Array [ + "meta": { + "transformations": [ "ensureColumns", ], }, diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts index d8c458840ce..b8b10dae669 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts @@ -128,34 +128,34 @@ describe('Grouping to Matrix', () => { const processed = received[0]; expect(processed[0].fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "Row\\\\Column", + [ + { + "config": {}, + "name": "Row\\Column", "type": "string", - "values": Array [ + "values": [ "R1", "R2", ], }, - Object { - "config": Object { + { + "config": { "units": "celsius", }, "name": "C1", "type": "number", - "values": Array [ + "values": [ 1, 4, ], }, - Object { - "config": Object { + { + "config": { "units": "celsius", }, "name": "C2", "type": "number", - "values": Array [ + "values": [ 5, "", ], diff --git a/packages/grafana-data/src/transformations/transformers/histogram.test.ts b/packages/grafana-data/src/transformations/transformers/histogram.test.ts index a2f0b1952a2..98e127ee2f1 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.test.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.test.ts @@ -30,13 +30,13 @@ describe('histogram frames frames', () => { config: f.config, })) ).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object { + [ + { + "config": { "unit": "mph", }, "name": "BucketMin", - "values": Array [ + "values": [ 1, 2, 3, @@ -48,12 +48,12 @@ describe('histogram frames frames', () => { 9, ], }, - Object { - "config": Object { + { + "config": { "unit": "mph", }, "name": "BucketMax", - "values": Array [ + "values": [ 2, 3, 4, @@ -65,12 +65,12 @@ describe('histogram frames frames', () => { 10, ], }, - Object { - "config": Object { + { + "config": { "unit": undefined, }, "name": "A", - "values": Array [ + "values": [ 1, 1, 1, @@ -82,12 +82,12 @@ describe('histogram frames frames', () => { 0, ], }, - Object { - "config": Object { + { + "config": { "unit": undefined, }, "name": "B", - "values": Array [ + "values": [ 0, 0, 1, @@ -99,12 +99,12 @@ describe('histogram frames frames', () => { 0, ], }, - Object { - "config": Object { + { + "config": { "unit": undefined, }, "name": "C", - "values": Array [ + "values": [ 0, 0, 0, @@ -116,12 +116,12 @@ describe('histogram frames frames', () => { 1, ], }, - Object { - "config": Object { + { + "config": { "unit": undefined, }, "name": "C", - "values": Array [ + "values": [ 0, 0, 0, @@ -143,10 +143,10 @@ describe('histogram frames frames', () => { values: f.values.toArray(), })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "BucketMin", - "values": Array [ + "values": [ 1, 2, 3, @@ -158,9 +158,9 @@ describe('histogram frames frames', () => { 9, ], }, - Object { + { "name": "BucketMax", - "values": Array [ + "values": [ 2, 3, 4, @@ -172,9 +172,9 @@ describe('histogram frames frames', () => { 10, ], }, - Object { + { "name": "Count", - "values": Array [ + "values": [ 1, 1, 2, diff --git a/packages/grafana-data/src/transformations/transformers/joinByField.test.ts b/packages/grafana-data/src/transformations/transformers/joinByField.test.ts index 95f330b4331..1a1026017b3 100644 --- a/packages/grafana-data/src/transformations/transformers/joinByField.test.ts +++ b/packages/grafana-data/src/transformations/transformers/joinByField.test.ts @@ -44,91 +44,91 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1000, - 3000, - 4000, - 5000, - 6000, - 7000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - undefined, - 10.3, - 10.4, - 10.5, - 10.6, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - undefined, - 10000.3, - 10000.4, - 10000.5, - 10000.6, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 11.1, - 11.3, - undefined, - 11.5, - undefined, - 11.7, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 11000.1, - 11000.3, - undefined, - 11000.5, - undefined, - 11000.7, - ], - }, - ] - `); + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], + }, + ] + `); } ); }); @@ -146,105 +146,105 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.4, - 10.5, - 10.6, - 11.1, - 11.3, - 11.5, - 11.7, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + [ + { + "config": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.4, + 10.5, + 10.6, + 11.1, + 11.3, + 11.5, + 11.7, + ], }, - "name": "time", - "state": Object { - "multipleFrames": true, + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + 3000, + 4000, + 5000, + 6000, + undefined, + undefined, + undefined, + undefined, + ], }, - "type": "time", - "values": Array [ - 3000, - 4000, - 5000, - 6000, - undefined, - undefined, - undefined, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + undefined, + undefined, + undefined, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.4, - 10000.5, - 10000.6, - undefined, - undefined, - undefined, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + undefined, + undefined, + undefined, + undefined, + 1000, + 3000, + 5000, + 7000, + ], }, - "name": "time", - "state": Object { - "multipleFrames": true, + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + 11000.1, + 11000.3, + 11000.5, + 11000.7, + ], }, - "type": "time", - "values": Array [ - undefined, - undefined, - undefined, - undefined, - 1000, - 3000, - 5000, - 7000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", - }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - undefined, - undefined, - undefined, - undefined, - 11000.1, - 11000.3, - 11000.5, - 11000.7, - ], - }, - ] - `); + ] + `); } ); }); @@ -266,93 +266,93 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object { - "multipleFrames": true, + [ + { + "config": {}, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], }, - "type": "time", - "values": Array [ - 1000, - 3000, - 4000, - 5000, - 6000, - 7000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - undefined, - 10.3, - 10.4, - 10.5, - 10.6, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "even", + { + "config": {}, + "labels": { + "name": "even", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - undefined, - 10000.3, - 10000.4, - 10000.5, - 10000.6, - undefined, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 11.1, - 11.3, - undefined, - 11.5, - undefined, - 11.7, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "odd", + { + "config": {}, + "labels": { + "name": "odd", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 11000.1, - 11000.3, - undefined, - 11000.5, - undefined, - 11000.7, - ], - }, - ] - `); + ] + `); } ); }); @@ -387,51 +387,51 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1000, - 2000, - 3000, - 4000, - ], - }, - Object { - "config": Object {}, - "labels": Object { + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1000, + 2000, + 3000, + 4000, + ], + }, + { + "config": {}, + "labels": { + "name": "temperature", + }, "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 1, + 3, + 5, + 7, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 1, - 3, - 5, - 7, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 2, + 4, + 6, + 8, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 2, - 4, - 6, - 8, - ], - }, - ] - `); + ] + `); } ); }); @@ -470,48 +470,48 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1, - 2, - 3, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1, + 2, + 3, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10, - 11, - 12, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "C", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10, + 11, + 12, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 20, - 22, - 24, - ], - }, - ] - `); + { + "config": {}, + "labels": { + "name": "C", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 20, + 22, + 24, + ], + }, + ] + `); }); }); @@ -541,38 +541,38 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1, - ], - }, - Object { - "config": Object {}, - "labels": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10, - ], - }, - Object { - "config": Object {}, - "labels": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 20, - ], - }, - ] - `); + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1, + ], + }, + { + "config": {}, + "labels": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10, + ], + }, + { + "config": {}, + "labels": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 20, + ], + }, + ] + `); }); }); }); @@ -609,71 +609,71 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 3000, - 5000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 3000, + 5000, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.5, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.5, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - ] - `); + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], + }, + ] + `); }); }); @@ -690,75 +690,75 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + [ + { + "config": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.5, + ], }, - "name": "time", - "state": Object { - "multipleFrames": true, + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + 3000, + 5000, + ], }, - "type": "time", - "values": Array [ - 3000, - 5000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + 3000, + 5000, + ], }, - "name": "time", - "state": Object { - "multipleFrames": true, + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], }, - "type": "time", - "values": Array [ - 3000, - 5000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", - }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - ] - `); + ] + `); }); }); @@ -779,73 +779,73 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object { - "multipleFrames": true, + [ + { + "config": {}, + "name": "time", + "state": { + "multipleFrames": true, + }, + "type": "time", + "values": [ + 3000, + 5000, + ], }, - "type": "time", - "values": Array [ - 3000, - 5000, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.5, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10.3, + 10.5, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10.3, - 10.5, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "humidity", + "state": {}, + "type": "number", + "values": [ + 10000.3, + 10000.5, + ], }, - "name": "humidity", - "state": Object {}, - "type": "number", - "values": Array [ - 10000.3, - 10000.5, - ], - }, - ] - `); + ] + `); }); }); @@ -880,51 +880,51 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1000, - 2000, - 3000, - 4000, - ], - }, - Object { - "config": Object {}, - "labels": Object { + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1000, + 2000, + 3000, + 4000, + ], + }, + { + "config": {}, + "labels": { + "name": "temperature", + }, "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 1, + 3, + 5, + 7, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 1, - 3, - 5, - 7, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "B", + { + "config": {}, + "labels": { + "name": "B", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 2, + 4, + 6, + 8, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 2, - 4, - 6, - 8, - ], - }, - ] - `); + ] + `); } ); }); @@ -964,48 +964,48 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1, - 2, - 3, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "A", + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1, + 2, + 3, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10, - 11, - 12, - ], - }, - Object { - "config": Object {}, - "labels": Object { - "name": "C", + { + "config": {}, + "labels": { + "name": "A", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10, + 11, + 12, + ], }, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 20, - 22, - 24, - ], - }, - ] - `); + { + "config": {}, + "labels": { + "name": "C", + }, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 20, + 22, + 24, + ], + }, + ] + `); }); }); @@ -1036,38 +1036,38 @@ describe('JOIN Transformer', () => { const data = received[0]; const filtered = data[0]; expect(filtered.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, - "name": "time", - "state": Object {}, - "type": "time", - "values": Array [ - 1, - ], - }, - Object { - "config": Object {}, - "labels": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 10, - ], - }, - Object { - "config": Object {}, - "labels": Object {}, - "name": "temperature", - "state": Object {}, - "type": "number", - "values": Array [ - 20, - ], - }, - ] - `); + [ + { + "config": {}, + "name": "time", + "state": {}, + "type": "time", + "values": [ + 1, + ], + }, + { + "config": {}, + "labels": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 10, + ], + }, + { + "config": {}, + "labels": {}, + "name": "temperature", + "state": {}, + "type": "number", + "values": [ + 20, + ], + }, + ] + `); }); }); }); diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts index d0d3332dce5..5f4ef23427b 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts @@ -36,42 +36,42 @@ describe('align frames', () => { values: f.values.toArray(), })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "values": Array [ + "values": [ 1000, 1500, 2000, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 1, undefined, 100, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 2, 20, 200, ], }, - Object { + { "name": "B", - "values": Array [ + "values": [ 3, 30, 300, ], }, - Object { + { "name": "C", - "values": Array [ + "values": [ "first", "second", "third", @@ -89,38 +89,38 @@ describe('align frames', () => { values: f.values.toArray(), })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "values": Array [ + "values": [ 1000, 2000, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 1, 100, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 2, 200, ], }, - Object { + { "name": "B", - "values": Array [ + "values": [ 3, 300, ], }, - Object { + { "name": "C", - "values": Array [ + "values": [ "first", "third", ], @@ -154,44 +154,44 @@ describe('align frames', () => { state: f.state, })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 0, "frameIndex": 0, }, }, - "values": Array [ + "values": [ 1000, 1500, 2000, ], }, - Object { + { "name": "A1", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 0, }, }, - "values": Array [ + "values": [ 1, 15, 2, ], }, - Object { + { "name": "A2", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 1, }, }, - "values": Array [ + "values": [ 1, undefined, 2, @@ -208,20 +208,20 @@ describe('align frames', () => { state: f.state, })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 0, "frameIndex": 0, }, }, }, - Object { + { "name": "A1", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 0, }, @@ -246,18 +246,18 @@ describe('align frames', () => { values: f.values.toArray(), })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "values": Array [ + "values": [ 1500, 2000, 6000, ], }, - Object { + { "name": "A1", - "values": Array [ + "values": [ 15, 22, 1, @@ -293,24 +293,24 @@ describe('align frames', () => { values: f.values.toArray(), })) ).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "name": "TheTime", - "values": Array [ + "values": [ 1000, 2000, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 1, 100, ], }, - Object { + { "name": "A", - "values": Array [ + "values": [ 200, undefined, ], diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts index 41dccaeb826..53fa647eb5a 100644 --- a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts @@ -28,20 +28,20 @@ describe('Labels as Columns', () => { await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { const data = received[0]; expect(toSimpleObject(data[0])).toMatchInlineSnapshot(` - Object { - "Value": Array [ + { + "Value": [ 1, 2, ], - "feelsLike": Array [ + "feelsLike": [ "ok", "ok", ], - "location": Array [ + "location": [ "inside", "inside", ], - "time": Array [ + "time": [ 1000, 2000, ], @@ -119,27 +119,27 @@ describe('Labels as Columns', () => { expect(data.length).toEqual(2); expect(toSimpleObject(data[0])).toMatchInlineSnapshot(` - Object { - "location": Array [ + { + "location": [ "inside", ], - "temp": Array [ + "temp": [ 1, ], - "time": Array [ + "time": [ 1000, ], } `); expect(toSimpleObject(data[1])).toMatchInlineSnapshot(` - Object { - "location": Array [ + { + "location": [ "outside", ], - "temp": Array [ + "temp": [ -1, ], - "time": Array [ + "time": [ 2000, ], } @@ -196,33 +196,33 @@ describe('Labels as Columns', () => { await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { expect(received[0][0].fields.map((f) => ({ [f.name]: f.values.toArray() }))).toMatchInlineSnapshot(` - Array [ - Object { - "time": Array [ + [ + { + "time": [ 1000, 2000, ], }, - Object { - "a": Array [ + { + "a": [ 1, 3, ], }, - Object { - "b": Array [ + { + "b": [ 2, 4, ], }, - Object { - "foo": Array [ + { + "foo": [ "thing", "thing", ], }, - Object { - "bar": Array [ + { + "bar": [ "thing", "thing", ], @@ -285,42 +285,42 @@ describe('Labels as Columns', () => { expect( received[0].map((f) => ({ name: f.name, fields: f.fields.map((v) => ({ [v.name]: v.values.toArray() })) })) ).toMatchInlineSnapshot(` - Array [ - Object { - "fields": Array [ - Object { - "label": Array [ + [ + { + "fields": [ + { + "label": [ "foo", "bar", "zaz", ], }, - Object { - "value": Array [ + { + "value": [ "thing", "a", "xyz", ], }, ], - "name": "a {bar=\\"a\\", foo=\\"thing\\", zaz=\\"xyz\\"}", + "name": "a {bar="a", foo="thing", zaz="xyz"}", }, - Object { - "fields": Array [ - Object { - "label": Array [ + { + "fields": [ + { + "label": [ "foo", "bar", ], }, - Object { - "value": Array [ + { + "value": [ "thing", "b", ], }, ], - "name": "b {bar=\\"b\\", foo=\\"thing\\"}", + "name": "b {bar="b", foo="thing"}", }, ] `); @@ -349,40 +349,40 @@ describe('Labels as Columns', () => { expect( received[0].map((f) => ({ name: f.name, fields: f.fields.map((v) => ({ [v.name]: v.values.toArray() })) })) ).toMatchInlineSnapshot(` - Array [ - Object { - "fields": Array [ - Object { - "label": Array [ + [ + { + "fields": [ + { + "label": [ "zaz", "bar", ], }, - Object { - "value": Array [ + { + "value": [ "xyz", "a", ], }, ], - "name": "a {bar=\\"a\\", foo=\\"thing\\", zaz=\\"xyz\\"}", + "name": "a {bar="a", foo="thing", zaz="xyz"}", }, - Object { - "fields": Array [ - Object { - "label": Array [ + { + "fields": [ + { + "label": [ "zaz", "bar", ], }, - Object { - "value": Array [ + { + "value": [ undefined, "b", ], }, ], - "name": "b {bar=\\"b\\", foo=\\"thing\\"}", + "name": "b {bar="b", foo="thing"}", }, ] `); diff --git a/packages/grafana-data/src/transformations/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts index 92369e07222..1825618d26a 100644 --- a/packages/grafana-data/src/transformations/transformers/reduce.test.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -264,12 +264,12 @@ describe('Reducer Transformer', () => { const view0 = new DataFrameView(frames[0]); const view1 = new DataFrameView(frames[1]); expect({ ...view0.get(0) }).toMatchInlineSnapshot(` - Object { + { "temperature": 6, } `); expect({ ...view1.get(0) }).toMatchInlineSnapshot(` - Object { + { "humidity": 10000.6, "temperature": 6, } @@ -398,45 +398,45 @@ describe('Reducer Transformer', () => { expect(processed.length).toEqual(1); expect(processed[0].fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object {}, + [ + { + "config": {}, "name": "Field", "type": "string", - "values": Array [ + "values": [ "value", "value", "value", "value", ], }, - Object { - "config": Object {}, + { + "config": {}, "name": "state", "type": "string", - "values": Array [ + "values": [ "CA", "NY", "CA", , ], }, - Object { - "config": Object {}, + { + "config": {}, "name": "country", "type": "string", - "values": Array [ + "values": [ , , "USA", "USA", ], }, - Object { - "config": Object {}, + { + "config": {}, "name": "Max", "type": "number", - "values": Array [ + "values": [ 6, 6, 6, diff --git a/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts index e7e3fc0d010..26e5b438d49 100644 --- a/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts +++ b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts @@ -42,35 +42,35 @@ describe('Rename By Regex Transformer', () => { const data = received[0]; const frame = data[0]; expect(frame.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object { + [ + { + "config": { "name": "Time", }, "name": "Time", - "state": Object { + "state": { "displayName": "Time", "multipleFrames": false, }, "type": "time", - "values": Array [ + "values": [ 3000, 4000, 5000, 6000, ], }, - Object { - "config": Object { + { + "config": { "displayName": "web-01", }, "name": "Value", - "state": Object { + "state": { "displayName": "web-01", "multipleFrames": false, }, "type": "number", - "values": Array [ + "values": [ 10000.3, 10000.4, 10000.5, @@ -94,36 +94,36 @@ describe('Rename By Regex Transformer', () => { const data = received[0]; const frame = data[0]; expect(frame.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object { + [ + { + "config": { "displayName": "TimE", "name": "Time", }, "name": "Time", - "state": Object { + "state": { "displayName": "TimE", "multipleFrames": false, }, "type": "time", - "values": Array [ + "values": [ 3000, 4000, 5000, 6000, ], }, - Object { - "config": Object { + { + "config": { "displayName": "wEb-01.ExamplE.com", }, "name": "Value", - "state": Object { + "state": { "displayName": "wEb-01.ExamplE.com", "multipleFrames": false, }, "type": "number", - "values": Array [ + "values": [ 10000.3, 10000.4, 10000.5, @@ -147,35 +147,35 @@ describe('Rename By Regex Transformer', () => { const data = received[0]; const frame = data[0]; expect(frame.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object { + [ + { + "config": { "name": "Time", }, "name": "Time", - "state": Object { + "state": { "displayName": "Time", "multipleFrames": false, }, "type": "time", - "values": Array [ + "values": [ 3000, 4000, 5000, 6000, ], }, - Object { - "config": Object { + { + "config": { "displayName": "web-01.example.com", }, "name": "Value", - "state": Object { + "state": { "displayName": "web-01.example.com", "multipleFrames": false, }, "type": "number", - "values": Array [ + "values": [ 10000.3, 10000.4, 10000.5, @@ -199,35 +199,35 @@ describe('Rename By Regex Transformer', () => { const data = received[0]; const frame = data[0]; expect(frame.fields).toMatchInlineSnapshot(` - Array [ - Object { - "config": Object { + [ + { + "config": { "name": "Time", }, "name": "Time", - "state": Object { + "state": { "displayName": "Time", "multipleFrames": false, }, "type": "time", - "values": Array [ + "values": [ 3000, 4000, 5000, 6000, ], }, - Object { - "config": Object { + { + "config": { "displayName": "web-01.example.com", }, "name": "Value", - "state": Object { + "state": { "displayName": "web-01.example.com", "multipleFrames": false, }, "type": "number", - "values": Array [ + "values": [ 10000.3, 10000.4, 10000.5, diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.test.ts b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts index da00368ac78..557a79e7308 100644 --- a/packages/grafana-data/src/transformations/transformers/sortBy.test.ts +++ b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts @@ -48,9 +48,9 @@ describe('SortBy transformer', () => { await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` - Object { + { "name": "time", - "values": Array [ + "values": [ 5, 6, 7, @@ -78,9 +78,9 @@ describe('SortBy transformer', () => { await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` - Object { + { "name": "time", - "values": Array [ + "values": [ 10, 9, 8, diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 0e592167184..c6f708056b0 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -127,6 +127,7 @@ export interface CurrentUserDTO { timezone: string; weekStart: string; locale: string; + language: string; permissions?: Record; } diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index abc73743db0..ef3a44f7775 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -11,6 +11,7 @@ export interface ExploreUrlState { range: RawTimeRange; context?: string; panelsState?: ExplorePanelsState; + isFromCompactUrl?: boolean; } export interface ExplorePanelsState extends Partial> { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c4b9823408c..5d9debb1609 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -59,7 +59,6 @@ export interface FeatureToggles { prometheusWideSeries?: boolean; canvasPanelNesting?: boolean; scenes?: boolean; - useLegacyHeatmapPanel?: boolean; disableSecretsCompatibility?: boolean; logRequestsInstrumentedAsUnknown?: boolean; dataConnectionsConsole?: boolean; diff --git a/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap index a505486dc54..f373bd9641c 100644 --- a/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap +++ b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap @@ -1,50 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`read csv should get X and y 1`] = ` -Object { - "fields": Array [ - Object { - "config": Object {}, +{ + "fields": [ + { + "config": {}, "labels": undefined, "name": "Field 1", "type": "string", - "values": Array [ + "values": [ "", "2", "5", "", ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "Field 2", "type": "number", - "values": Array [ + "values": [ 1, 3, 6, NaN, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "Field 3", "type": "number", - "values": Array [ + "values": [ undefined, 4, undefined, NaN, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "Field 4", "type": "number", - "values": Array [ + "values": [ undefined, undefined, undefined, @@ -59,34 +59,34 @@ Object { `; exports[`read csv should read csv from local file system 1`] = ` -Object { - "fields": Array [ - Object { - "config": Object {}, +{ + "fields": [ + { + "config": {}, "labels": undefined, "name": "a", "type": "number", - "values": Array [ + "values": [ 10, 40, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "b", "type": "number", - "values": Array [ + "values": [ 20, 50, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "c", "type": "number", - "values": Array [ + "values": [ 30, 60, ], @@ -99,44 +99,44 @@ Object { `; exports[`read csv should read csv with headers 1`] = ` -Object { - "fields": Array [ - Object { - "config": Object { +{ + "fields": [ + { + "config": { "unit": "ms", }, "labels": undefined, "name": "a", "type": "number", - "values": Array [ + "values": [ 10, 40, 40, 40, ], }, - Object { - "config": Object { + { + "config": { "unit": "lengthm", }, "labels": undefined, "name": "b", "type": "number", - "values": Array [ + "values": [ 20, 50, 500, 50, ], }, - Object { - "config": Object { + { + "config": { "unit": "s", }, "labels": undefined, "name": "c", "type": "boolean", - "values": Array [ + "values": [ true, false, false, diff --git a/packages/grafana-data/src/utils/csv.test.ts b/packages/grafana-data/src/utils/csv.test.ts index f6e6ff6817f..0512aca9fa4 100644 --- a/packages/grafana-data/src/utils/csv.test.ts +++ b/packages/grafana-data/src/utils/csv.test.ts @@ -102,7 +102,7 @@ describe('write csv', () => { const csv = toCSV([dataFrame], { useExcelHeader: true }); expect(csv).toMatchInlineSnapshot(` "sep=, - \\"Time\\",\\"Value\\" + "Time","Value" 1598784913123,1234 1598784914123,5678 @@ -130,7 +130,7 @@ describe('DataFrame to CSV', () => { const csv = toCSV([dataFrame]); expect(csv).toMatchInlineSnapshot(` - "\\"Time\\",\\"{label1=\\"\\"value1\\"\\", label2=\\"\\"value1\\"\\"}\\" + ""Time","{label1=""value1"", label2=""value1""}" 1589455688623,1234 " @@ -159,7 +159,7 @@ describe('DataFrame to CSV', () => { const csv = toCSV([dataFrame]); expect(csv).toMatchInlineSnapshot(` - "\\"Time\\",\\"Value\\" + ""Time","Value" 1589455688623,2020-05-14 11:28:08 " diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts index 0d66dff6ded..e2a318e95d4 100644 --- a/packages/grafana-data/src/vector/SortedVector.ts +++ b/packages/grafana-data/src/vector/SortedVector.ts @@ -23,4 +23,8 @@ export class SortedVector implements Vector { toJSON(): T[] { return vectorToArray(this); } + + getOrderArray(): number[] { + return this.order; + } } diff --git a/packages/grafana-e2e/cli.js b/packages/grafana-e2e/cli.js index c2d42b204e4..af51c9b31b1 100644 --- a/packages/grafana-e2e/cli.js +++ b/packages/grafana-e2e/cli.js @@ -3,7 +3,7 @@ const execa = require('execa'); const { resolve, sep } = require('path'); const resolveBin = require('resolve-as-bin'); -const cypress = (commandName, { updateScreenshots }) => { +const cypress = (commandName, { updateScreenshots, browser }) => { // Support running an unpublished dev build const dirname = __dirname.split(sep).pop(); const projectPath = resolve(`${__dirname}${dirname === 'dist' ? '/..' : ''}`); @@ -16,6 +16,10 @@ const cypress = (commandName, { updateScreenshots }) => { const cypressOptions = [commandName, '--env', `${CWD},${UPDATE_SCREENSHOTS}`, `--project=${projectPath}`]; + if (browser) { + cypressOptions.push('--browser', browser); + } + const execaOptions = { cwd: __dirname, stdio: 'inherit', @@ -32,17 +36,21 @@ const cypress = (commandName, { updateScreenshots }) => { module.exports = () => { const updateOption = '-u, --update-screenshots'; const updateDescription = 'update expected screenshots'; + const browserOption = '-b, --browser '; + const browserDescription = 'specify which browser to use'; program .command('open') .description('runs tests within the interactive GUI') .option(updateOption, updateDescription) + .option(browserOption, browserDescription) .action((options) => cypress('open', options)); program .command('run') .description('runs tests from the CLI without the GUI') .option(updateOption, updateDescription) + .option(browserOption, browserDescription) .action((options) => cypress('run', options)); program.parse(process.argv); diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 6615eb7c6ce..a724775f4fd 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -56,7 +56,7 @@ "@testing-library/user-event": "14.4.3", "@types/angular": "1.8.4", "@types/history": "4.7.11", - "@types/jest": "28.1.6", + "@types/jest": "29.2.3", "@types/lodash": "4.14.187", "@types/react": "17.0.42", "@types/react-dom": "17.0.14", diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index d92e8cab4cc..fa85ac4b2b2 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -142,6 +142,8 @@ export class GrafanaBootConfig implements GrafanaConfig { rudderstackSdkUrl: undefined; rudderstackConfigUrl: undefined; + tokenExpirationDayLimit: undefined; + constructor(options: GrafanaBootConfig) { this.bootData = options.bootData; this.isPublicDashboardView = options.bootData.settings.isPublicDashboardView; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts index 7c996cea7c5..34a60eb96bb 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts @@ -42,7 +42,8 @@ jest.mock('../services', () => ({ describe('DataSourceWithBackend', () => { test('check the executed queries', () => { - const mock = runQueryAndReturnFetchMock({ + const { mock, ds } = createMockDatasource(); + ds.query({ maxDataPoints: 10, intervalMs: 5000, targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }], @@ -54,11 +55,11 @@ describe('DataSourceWithBackend', () => { expect(mock.calls.length).toBe(1); expect(args).toMatchInlineSnapshot(` - Object { - "data": Object { - "queries": Array [ - Object { - "datasource": Object { + { + "data": { + "queries": [ + { + "datasource": { "type": "dummy", "uid": "abc", }, @@ -67,8 +68,8 @@ describe('DataSourceWithBackend', () => { "maxDataPoints": 10, "refId": "A", }, - Object { - "datasource": Object { + { + "datasource": { "type": "sample", "uid": "", }, @@ -79,7 +80,7 @@ describe('DataSourceWithBackend', () => { }, ], }, - "headers": Object { + "headers": { "X-Dashboard-Uid": "dashA", "X-Datasource-Uid": "abc, ", "X-Panel-Id": "123", @@ -93,8 +94,22 @@ describe('DataSourceWithBackend', () => { `); }); + test('should apply template variables only for the current data source', () => { + const { mock, ds } = createMockDatasource(); + ds.applyTemplateVariables = jest.fn(); + ds.query({ + maxDataPoints: 10, + intervalMs: 5000, + targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }], + } as DataQueryRequest); + + expect(mock.calls.length).toBe(1); + expect(ds.applyTemplateVariables).toHaveBeenCalledTimes(1); + }); + test('check that the executed queries is hidden from inspector', () => { - const mock = runQueryAndReturnFetchMock({ + const { mock, ds } = createMockDatasource(); + ds.query({ maxDataPoints: 10, intervalMs: 5000, targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }], @@ -107,11 +122,11 @@ describe('DataSourceWithBackend', () => { expect(mock.calls.length).toBe(1); expect(args).toMatchInlineSnapshot(` - Object { - "data": Object { - "queries": Array [ - Object { - "datasource": Object { + { + "data": { + "queries": [ + { + "datasource": { "type": "dummy", "uid": "abc", }, @@ -120,8 +135,8 @@ describe('DataSourceWithBackend', () => { "maxDataPoints": 10, "refId": "A", }, - Object { - "datasource": Object { + { + "datasource": { "type": "sample", "uid": "", }, @@ -132,7 +147,7 @@ describe('DataSourceWithBackend', () => { }, ], }, - "headers": Object { + "headers": { "X-Dashboard-Uid": "dashA", "X-Datasource-Uid": "abc, ", "X-Panel-Id": "123", @@ -169,9 +184,7 @@ describe('DataSourceWithBackend', () => { }); }); -function runQueryAndReturnFetchMock( - request: DataQueryRequest -): jest.MockContext, BackendSrvRequest[]> { +function createMockDatasource() { const settings = { name: 'test', id: 1234, @@ -184,7 +197,5 @@ function runQueryAndReturnFetchMock( mockDatasourceRequest.mockReturnValue(Promise.resolve({} as FetchResponse)); const ds = new MyDataSource(settings); - ds.query(request); - - return mockDatasourceRequest.mock; + return { ds, mock: mockDatasourceRequest.mock }; } diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 9cdc73b7d72..b35a7485368 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -133,6 +133,7 @@ class DataSourceWithBackend< const queries = targets.map((q) => { let datasource = this.getRef(); let datasourceId = this.id; + let shouldApplyTemplateVariables = true; if (isExpressionReference(q.datasource)) { hasExpr = true; @@ -149,8 +150,15 @@ class DataSourceWithBackend< throw new Error(`Unknown Datasource: ${JSON.stringify(q.datasource)}`); } - datasource = ds.rawRef ?? getDataSourceRef(ds); - datasourceId = ds.id; + const dsRef = ds.rawRef ?? getDataSourceRef(ds); + const dsId = ds.id; + if (dsRef.uid !== datasource.uid || datasourceId !== dsId) { + datasource = dsRef; + datasourceId = dsId; + // If the query is using a different datasource, we would need to retrieve the datasource + // instance (async) and apply the template variables but it seems it's not necessary for now. + shouldApplyTemplateVariables = false; + } } if (datasource.type?.length) { pluginIDs.add(datasource.type); @@ -159,7 +167,7 @@ class DataSourceWithBackend< dsUIDs.add(datasource.uid); } return { - ...this.applyTemplateVariables(q, request.scopedVars), + ...(shouldApplyTemplateVariables ? this.applyTemplateVariables(q, request.scopedVars) : q), datasource, datasourceId, // deprecated! intervalMs, @@ -291,7 +299,7 @@ class DataSourceWithBackend< const result = await lastValueFrom( getBackendSrv().fetch({ ...options, - method: 'GET', + method: 'POST', headers: options?.headers ? { ...options.headers, ...headers } : headers, data: data ?? { ...data }, url: `/api/datasources/${this.id}/resources/${path}`, diff --git a/packages/grafana-runtime/src/utils/queryResponse.test.ts b/packages/grafana-runtime/src/utils/queryResponse.test.ts index f99fb5b2dc8..e14ea0eed7f 100644 --- a/packages/grafana-runtime/src/utils/queryResponse.test.ts +++ b/packages/grafana-runtime/src/utils/queryResponse.test.ts @@ -91,15 +91,15 @@ describe('Query Response parser', () => { const norm = frames.map((f) => toDataFrameDTO(f)); expect(norm).toMatchInlineSnapshot(` - Array [ - Object { - "fields": Array [ - Object { - "config": Object {}, + [ + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "time", "type": "time", - "values": Array [ + "values": [ 1611767228473, 1611767240473, 1611767252473, @@ -108,12 +108,12 @@ describe('Query Response parser', () => { 1611767288473, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "A-series", "type": "number", - "values": Array [ + "values": [ 1, 20, 90, @@ -127,14 +127,14 @@ describe('Query Response parser', () => { "name": undefined, "refId": "A", }, - Object { - "fields": Array [ - Object { - "config": Object {}, + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "time", "type": "time", - "values": Array [ + "values": [ 1611767228473, 1611767240473, 1611767252473, @@ -143,12 +143,12 @@ describe('Query Response parser', () => { 1611767288473, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "B-series", "type": "number", - "values": Array [ + "values": [ 1, 20, 90, @@ -176,15 +176,15 @@ describe('Query Response parser', () => { const norm = frames.map((f) => toDataFrameDTO(f)); expect(norm).toMatchInlineSnapshot(` - Array [ - Object { - "fields": Array [ - Object { - "config": Object {}, + [ + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "time", "type": "time", - "values": Array [ + "values": [ 1611767228473, 1611767240473, 1611767252473, @@ -193,12 +193,12 @@ describe('Query Response parser', () => { 1611767288473, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "B-series", "type": "number", - "values": Array [ + "values": [ 1, 20, 90, @@ -212,14 +212,14 @@ describe('Query Response parser', () => { "name": undefined, "refId": "B", }, - Object { - "fields": Array [ - Object { - "config": Object {}, + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "time", "type": "time", - "values": Array [ + "values": [ 1611767228473, 1611767240473, 1611767252473, @@ -228,12 +228,12 @@ describe('Query Response parser', () => { 1611767288473, ], }, - Object { - "config": Object {}, + { + "config": {}, "labels": undefined, "name": "A-series", "type": "number", - "values": Array [ + "values": [ 1, 20, 90, @@ -351,7 +351,7 @@ describe('Query Response parser', () => { // } const res = toDataQueryResponse(resWithError); expect(res.error).toMatchInlineSnapshot(` - Object { + { "message": "Hello Error", "refId": "A", } @@ -359,23 +359,23 @@ describe('Query Response parser', () => { const norm = res.data.map((f) => toDataFrameDTO(f)); expect(norm).toMatchInlineSnapshot(` - Array [ - Object { - "fields": Array [ - Object { - "config": Object {}, + [ + { + "fields": [ + { + "config": {}, "labels": undefined, "name": "numbers", "type": "number", - "values": Array [ + "values": [ 1, 3, ], }, ], - "meta": Object { - "notices": Array [ - Object { + "meta": { + "notices": [ + { "severity": 2, "text": "Text", }, diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 7bcf55da260..f89a0ae4abf 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -4,6 +4,7 @@ // kinds/gen.go // Using jennies: // TSTypesJenny +// LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts b/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts index 1e1ceeabd8d..a4fe7fc3d15 100644 --- a/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts +++ b/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts @@ -4,6 +4,7 @@ // kinds/gen.go // Using jennies: // TSTypesJenny +// LatestMajorsOrXJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index e69b1f0876c..bc938f89625 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -145,7 +145,7 @@ "@types/enzyme-adapter-react-16": "1.0.6", "@types/hoist-non-react-statics": "3.3.1", "@types/is-hotkey": "0.1.7", - "@types/jest": "28.1.6", + "@types/jest": "29.2.3", "@types/jquery": "3.5.14", "@types/lodash": "4.14.187", "@types/mock-raf": "1.0.3", diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index 9a37d35e210..0265dc5f8ac 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useRef, useState, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '../../../src/utils/i18n'; import { useStyles2 } from '../../themes'; import { Button, ButtonProps } from '../Button'; import { Icon } from '../Icon/Icon'; @@ -62,7 +63,7 @@ export function ClipboardButton({ <> {showCopySuccess && ( - Copied + Copied )} diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index 76f1c0c9826..9676bba702e 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -4,6 +4,7 @@ import React, { FunctionComponent, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; +import { clearButtonStyles } from '../Button'; import { Icon } from '../Icon/Icon'; const getStyles = (theme: GrafanaTheme2) => ({ @@ -71,9 +72,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ label: collapse__header; padding: ${theme.spacing(1, 2, 1, 2)}; display: flex; - cursor: inherit; transition: all 0.1s linear; - cursor: pointer; `, headerCollapsed: css` label: collapse__header--collapsed; @@ -132,6 +131,7 @@ export const Collapse = ({ className, children, }: React.PropsWithChildren) => { + const buttonStyles = useStyles2(clearButtonStyles); const style = useStyles2(getStyles); const onClickToggle = () => { if (onToggle) { @@ -145,10 +145,10 @@ export const Collapse = ({ return (
-
+
+ {isOpen && (
diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.story.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.story.tsx index 2255b431718..4135e314a1f 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.story.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.story.tsx @@ -46,6 +46,7 @@ export const Basic: ComponentStory = ({ body, description, confirmText, + confirmButtonVariant, dismissText, icon, isOpen, @@ -58,6 +59,7 @@ export const Basic: ComponentStory = ({ body={body} description={description} confirmText={confirmText} + confirmButtonVariant={confirmButtonVariant} dismissText={dismissText} icon={icon} onConfirm={onConfirm} @@ -77,6 +79,7 @@ Basic.args = { body: 'Are you sure you want to delete this user?', description: 'Removing the user will not remove any dashboards the user has created', confirmText: 'Delete', + confirmButtonVariant: 'destructive', dismissText: 'Cancel', icon: 'exclamation-triangle', isOpen: true, @@ -112,7 +115,7 @@ export const AlternativeAction: ComponentStory = ({ AlternativeAction.parameters = { controls: { - exclude: [...defaultExcludes, 'confirmationText'], + exclude: [...defaultExcludes, 'confirmationText', 'confirmButtonVariant'], }, }; @@ -155,7 +158,7 @@ export const WithConfirmation: ComponentStory = ({ WithConfirmation.parameters = { controls: { - exclude: [...defaultExcludes, 'alternativeText'], + exclude: [...defaultExcludes, 'alternativeText', 'confirmButtonVariant'], }, }; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index 0251fbcfd0c..86058fa945f 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { HorizontalGroup, Input } from '..'; import { useStyles2 } from '../../themes'; import { IconName } from '../../types/icon'; -import { Button } from '../Button'; +import { Button, ButtonVariant } from '../Button'; import { Modal } from '../Modal/Modal'; export interface ConfirmModalProps { @@ -31,6 +31,8 @@ export interface ConfirmModalProps { confirmationText?: string; /** Text for alternative button */ alternativeText?: string; + /** Confirm button variant */ + confirmButtonVariant?: ButtonVariant; /** Confirm action callback */ onConfirm(): void; /** Dismiss action callback */ @@ -53,6 +55,7 @@ export const ConfirmModal = ({ onConfirm, onDismiss, onAlternative, + confirmButtonVariant = 'destructive', }: ConfirmModalProps): JSX.Element => { const [disabled, setDisabled] = useState(Boolean(confirmationText)); const styles = useStyles2(getStyles); @@ -86,7 +89,7 @@ export const ConfirmModal = ({ {dismissText}
+ ); }; const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` - padding: ${theme.spacing(0.25)} ${theme.spacing(1)}; background: ${theme.colors.background.secondary}; border-radius: ${theme.shape.borderRadius(8)}; padding: ${theme.spacing(0, 2)}; + font-size: ${theme.typography.bodySmall.fontSize}; font-weight: ${theme.typography.fontWeightMedium}; - font-size: ${theme.typography.size.sm}; + line-height: ${theme.typography.bodySmall.lineHeight}; color: ${theme.colors.text.secondary}; display: flex; align-items: center; height: 32px; - cursor: pointer; &:hover { background: ${theme.colors.action.hover}; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx index 0a337707c36..00037a9872c 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -19,6 +19,9 @@ const field: FieldConfig = { mode: ThresholdsMode.Absolute, steps: [{ value: -Infinity, color: '#7EB26D' }], }, + custom: { + neeutral: 0, + }, }; const props: Props = { diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 39e7a80808e..1f54b72aff4 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -98,6 +98,7 @@ export class Gauge extends PureComponent { gauge: { min, max, + neutralValue: field.custom?.neutral, background: { color: backgroundColor }, border: { color: null }, shadow: { show: false }, diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap index f9c3fbbab73..33400367468 100644 --- a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` -Object { - "axes": Array [ - Object { +{ + "axes": [ + { "filter": undefined, - "font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "font": "12px "Roboto", "Helvetica", "Arial", sans-serif", "gap": 5, - "grid": Object { + "grid": { "show": true, "stroke": "rgba(240, 250, 255, 0.09)", "width": 1, @@ -21,7 +21,7 @@ Object { "space": [Function], "splits": undefined, "stroke": "rgb(204, 204, 220)", - "ticks": Object { + "ticks": { "show": true, "size": 4, "stroke": "rgba(240, 250, 255, 0.09)", @@ -30,11 +30,11 @@ Object { "timeZone": "utc", "values": [Function], }, - Object { + { "filter": undefined, - "font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "font": "12px "Roboto", "Helvetica", "Arial", sans-serif", "gap": 5, - "grid": Object { + "grid": { "show": true, "stroke": "rgba(240, 250, 255, 0.09)", "width": 1, @@ -48,7 +48,7 @@ Object { "space": [Function], "splits": undefined, "stroke": "rgb(204, 204, 220)", - "ticks": Object { + "ticks": { "show": true, "size": 4, "stroke": "rgba(240, 250, 255, 0.09)", @@ -58,48 +58,48 @@ Object { "values": [Function], }, ], - "cursor": Object { + "cursor": { "dataIdx": [Function], - "drag": Object { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, - "sync": Object { - "filters": Object { + "sync": { + "filters": { "pub": [Function], }, "key": "__global_", - "scales": Array [ + "scales": [ "x", "__fixed/na-na/na-na/auto/linear/na", ], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object { - "__fixed/na-na/na-na/auto/linear/na": Object { + "scales": { + "__fixed/na-na/na-na/auto/linear/na": { "asinh": undefined, "auto": true, "dir": 1, @@ -109,7 +109,7 @@ Object { "range": [Function], "time": undefined, }, - "x": Object { + "x": { "auto": false, "dir": 1, "ori": 0, @@ -118,12 +118,12 @@ Object { }, }, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, - Object { - "dash": Array [ + { + "dash": [ 1, 2, ], @@ -131,7 +131,7 @@ Object { "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": [Function], "show": true, @@ -146,8 +146,8 @@ Object { "value": [Function], "width": 2, }, - Object { - "dash": Array [ + { + "dash": [ 1, 2, ], @@ -155,7 +155,7 @@ Object { "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": [Function], "show": true, @@ -170,8 +170,8 @@ Object { "value": [Function], "width": 2, }, - Object { - "dash": Array [ + { + "dash": [ 1, 2, ], @@ -179,7 +179,7 @@ Object { "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": [Function], "show": true, @@ -194,8 +194,8 @@ Object { "value": [Function], "width": 2, }, - Object { - "dash": Array [ + { + "dash": [ 1, 2, ], @@ -203,7 +203,7 @@ Object { "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": [Function], "show": true, @@ -218,8 +218,8 @@ Object { "value": [Function], "width": 2, }, - Object { - "dash": Array [ + { + "dash": [ 1, 2, ], @@ -227,7 +227,7 @@ Object { "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": [Function], "show": true, diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/components/GraphNG/utils.test.ts index 6f44020a288..e385018d12a 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.test.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.test.ts @@ -322,20 +322,20 @@ describe('GraphNG utils', () => { }); expect(aligndFrame).toMatchInlineSnapshot(` - Object { - "fields": Array [ - Object { - "config": Object {}, + { + "fields": [ + { + "config": {}, "name": "time", - "state": Object { + "state": { "nullThresholdApplied": true, - "origin": Object { + "origin": { "fieldIndex": 0, "frameIndex": 0, }, }, "type": "time", - "values": Array [ + "values": [ 1, 1.1, 2, @@ -350,25 +350,25 @@ describe('GraphNG utils', () => { 102, ], }, - Object { - "config": Object { - "custom": Object { + { + "config": { + "custom": { "drawStyle": "bars", "spanNulls": -1, }, }, - "labels": Object { + "labels": { "name": "A", }, "name": "value", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 0, }, }, "type": "number", - "values": Array [ + "values": [ 1, undefined, 1, @@ -383,25 +383,25 @@ describe('GraphNG utils', () => { null, ], }, - Object { - "config": Object { - "custom": Object { + { + "config": { + "custom": { "drawStyle": "bars", "spanNulls": -1, }, }, - "labels": Object { + "labels": { "name": "B", }, "name": "value", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 1, }, }, "type": "number", - "values": Array [ + "values": [ undefined, undefined, undefined, @@ -416,24 +416,24 @@ describe('GraphNG utils', () => { null, ], }, - Object { - "config": Object { - "custom": Object { + { + "config": { + "custom": { "drawStyle": "line", }, }, - "labels": Object { + "labels": { "name": "B", }, "name": "value", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 2, "frameIndex": 1, }, }, "type": "number", - "values": Array [ + "values": [ undefined, undefined, undefined, @@ -448,24 +448,24 @@ describe('GraphNG utils', () => { undefined, ], }, - Object { - "config": Object { - "custom": Object { + { + "config": { + "custom": { "drawStyle": "line", }, }, - "labels": Object { + "labels": { "name": "C", }, "name": "value", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 1, "frameIndex": 2, }, }, "type": "number", - "values": Array [ + "values": [ 4, 4, undefined, @@ -480,27 +480,27 @@ describe('GraphNG utils', () => { undefined, ], }, - Object { - "config": Object { - "custom": Object { + { + "config": { + "custom": { "drawStyle": "bars", - "hideFrom": Object { + "hideFrom": { "viz": true, }, }, }, - "labels": Object { + "labels": { "name": "C", }, "name": "value", - "state": Object { - "origin": Object { + "state": { + "origin": { "fieldIndex": 2, "frameIndex": 2, }, }, "type": "number", - "values": Array [ + "values": [ 4, 4, undefined, diff --git a/packages/grafana-ui/src/components/Menu/Menu.story.tsx b/packages/grafana-ui/src/components/Menu/Menu.story.tsx index 80499f0e3b5..2c72671a348 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.story.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.story.tsx @@ -49,6 +49,31 @@ export function Examples() { + + + + + + , + , + ]} + /> + , + , + ]} + /> + + + { return { - header: css` - padding: ${theme.spacing(0.5, 0.5, 1, 0.5)}; - border-bottom: 1px solid ${theme.colors.border.weak}; - `, - wrapper: css` - background: ${theme.colors.background.primary}; - box-shadow: ${theme.shadows.z3}; - display: inline-block; - border-radius: ${theme.shape.borderRadius()}; - padding: ${theme.spacing(0.5, 0)}; - `, + header: css({ + padding: `${theme.spacing(0.5, 0.5, 1, 0.5)}`, + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + wrapper: css({ + background: `${theme.colors.background.primary}`, + boxShadow: `${theme.shadows.z3}`, + display: `inline-block`, + borderRadius: `${theme.shape.borderRadius()}`, + padding: `${theme.spacing(0.5, 0)}`, + }), }; }; diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx index 39f5719361b..5371d1c7674 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.test.tsx @@ -51,6 +51,20 @@ describe('MenuItem', () => { expect(subMenuContainer.firstChild?.childNodes.length).toBe(2); }); + it('renders disabled subMenu correctly', async () => { + const childItems = [ + , + , + ]; + + render(getMenuItem({ childItems, disabled: true })); + + fireEvent.mouseOver(screen.getByLabelText(selectors.components.Menu.MenuItem('Test'))); + + const subMenuContainer = screen.queryByLabelText(selectors.components.Menu.SubMenu.container); + expect(subMenuContainer).toBe(null); + }); + it('opens subMenu on ArrowRight', async () => { const childItems = [ , diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 84b8c3f0520..47c9940a6b8 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -35,6 +35,8 @@ export interface MenuItemProps { className?: string; /** Active */ active?: boolean; + /** Disabled */ + disabled?: boolean; /** Show in destructive style (error color) */ destructive?: boolean; tabIndex?: number; @@ -57,6 +59,7 @@ export const MenuItem = React.memo( onClick, className, active, + disabled, destructive, childItems, role = 'menuitem', @@ -68,13 +71,21 @@ export const MenuItem = React.memo( const [isSubMenuOpen, setIsSubMenuOpen] = useState(false); const [openedWithArrow, setOpenedWithArrow] = useState(false); const onMouseEnter = useCallback(() => { + if (disabled) { + return; + } + setIsSubMenuOpen(true); setIsActive(true); - }, []); + }, [disabled]); const onMouseLeave = useCallback(() => { + if (disabled) { + return; + } + setIsSubMenuOpen(false); setIsActive(false); - }, []); + }, [disabled]); const hasSubMenu = childItems && childItems.length > 0; const ItemElement = hasSubMenu ? 'div' : url === undefined ? 'button' : 'a'; @@ -82,10 +93,19 @@ export const MenuItem = React.memo( { [styles.item]: true, [styles.active]: isActive, - [styles.destructive]: destructive, + [styles.disabled]: disabled, + [styles.destructive]: destructive && !disabled, }, className ); + const disabledProps = { + [ItemElement === 'button' ? 'disabled' : 'aria-disabled']: disabled, + ...(ItemElement === 'a' && disabled && { href: undefined, onClick: undefined }), + ...(disabled && { + tabIndex: -1, + ['data-disabled']: disabled, // used to identify disabled items in Menu.tsx + }), + }; const localRef = useRef(null); useImperativeHandle(ref, () => localRef.current!); @@ -128,6 +148,7 @@ export const MenuItem = React.memo( aria-label={ariaLabel} aria-checked={ariaChecked} tabIndex={tabIndex} + {...disabledProps} > <> {icon && } @@ -200,6 +221,17 @@ const getStyles = (theme: GrafanaTheme2) => { } } `, + disabled: css` + color: ${theme.colors.action.disabledText}; + + &:hover, + &:focus, + &:focus-visible { + cursor: not-allowed; + background: none; + color: ${theme.colors.action.disabledText}; + } + `, icon: css` opacity: 0.7; margin-right: 10px; diff --git a/packages/grafana-ui/src/components/Menu/hooks.test.tsx b/packages/grafana-ui/src/components/Menu/hooks.test.tsx index a35406e6a35..2c8bfabf886 100644 --- a/packages/grafana-ui/src/components/Menu/hooks.test.tsx +++ b/packages/grafana-ui/src/components/Menu/hooks.test.tsx @@ -18,7 +18,10 @@ describe('useMenuFocus', () => { Item 1 Item 2 - Item 3 + + Item 3 + + Item 4
); @@ -31,6 +34,7 @@ describe('useMenuFocus', () => { expect(screen.getByText('Item 1').tabIndex).toBe(-1); expect(screen.getByText('Item 2').tabIndex).toBe(-1); expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(-1); act(() => { fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' }); @@ -42,6 +46,7 @@ describe('useMenuFocus', () => { expect(screen.getByText('Item 1').tabIndex).toBe(0); expect(screen.getByText('Item 2').tabIndex).toBe(-1); expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(-1); act(() => { fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' }); @@ -53,6 +58,7 @@ describe('useMenuFocus', () => { expect(screen.getByText('Item 1').tabIndex).toBe(-1); expect(screen.getByText('Item 2').tabIndex).toBe(0); expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(-1); act(() => { fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' }); @@ -64,6 +70,7 @@ describe('useMenuFocus', () => { expect(screen.getByText('Item 1').tabIndex).toBe(0); expect(screen.getByText('Item 2').tabIndex).toBe(-1); expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(-1); act(() => { fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' }); @@ -74,7 +81,20 @@ describe('useMenuFocus', () => { expect(screen.getByText('Item 1').tabIndex).toBe(-1); expect(screen.getByText('Item 2').tabIndex).toBe(-1); - expect(screen.getByText('Item 3').tabIndex).toBe(0); + expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(0); + + act(() => { + fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' }); + }); + + const [handleKeys6] = result.current; + rerender(getMenuElement(ref, handleKeys6)); + + expect(screen.getByText('Item 1').tabIndex).toBe(-1); + expect(screen.getByText('Item 2').tabIndex).toBe(0); + expect(screen.getByText('Item 3').tabIndex).toBe(-1); + expect(screen.getByText('Item 4').tabIndex).toBe(-1); }); it('calls close on ArrowLeft and unfocuses all items', () => { diff --git a/packages/grafana-ui/src/components/Menu/hooks.ts b/packages/grafana-ui/src/components/Menu/hooks.ts index f021986fafc..3ca3d2baf5b 100644 --- a/packages/grafana-ui/src/components/Menu/hooks.ts +++ b/packages/grafana-ui/src/components/Menu/hooks.ts @@ -41,7 +41,7 @@ export const useMenuFocus = ({ useEffect(() => { const menuItems = localRef?.current?.querySelectorAll( - `[data-role="menuitem"]` + '[data-role="menuitem"]:not([data-disabled])' ); menuItems?.[focusedItem]?.focus(); menuItems?.forEach((menuItem, i) => { @@ -51,7 +51,7 @@ export const useMenuFocus = ({ useEffectOnce(() => { const firstMenuItem = localRef?.current?.querySelector( - `[data-role="menuitem"]` + '[data-role="menuitem"]:not([data-disabled])' ); if (firstMenuItem) { firstMenuItem.tabIndex = 0; @@ -61,7 +61,7 @@ export const useMenuFocus = ({ const handleKeys = (event: React.KeyboardEvent) => { const menuItems = localRef?.current?.querySelectorAll( - `[data-role="menuitem"]` + '[data-role="menuitem"]:not([data-disabled])' ); const menuItemsCount = menuItems?.length ?? 0; diff --git a/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx index bdf721f621f..d18e460b6e8 100644 --- a/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/ErrorIndicator.tsx @@ -38,11 +38,11 @@ export const ErrorIndicator: React.FC = ({ error, onClick } const getStyles = () => { return { - clickable: css` - cursor: pointer; - `, - icon: css` - color: ${commonColorsPalette.red88}; - `, + clickable: css({ + cursor: 'pointer', + }), + icon: css({ + color: `${commonColorsPalette.red88}`, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx index a47b08a4a9a..610e0382fc8 100644 --- a/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/LoadingIndicator.tsx @@ -40,8 +40,8 @@ export const LoadingIndicator: React.FC = ({ onCancel, lo const getStyles = () => { return { - clickable: css` - cursor: pointer; - `, + clickable: css({ + cursor: 'pointer', + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx index 6d98df8311b..7e5247cb66d 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx @@ -4,11 +4,14 @@ import { merge } from 'lodash'; import React, { CSSProperties, useState, ReactNode } from 'react'; import { useInterval } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; -import { PanelChrome, useTheme2, PanelChromeProps } from '@grafana/ui'; +import { PanelChrome, PanelChromeProps } from '@grafana/ui'; +import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { HorizontalGroup, VerticalGroup } from '../Layout/Layout'; +import { Menu } from '../Menu/Menu'; + +import { PanelChromeInfoState } from './PanelChrome'; const meta: ComponentMeta = { title: 'Visualizations/PanelChrome', @@ -22,17 +25,16 @@ const meta: ComponentMeta = { }, }; -function getContentStyle(theme: GrafanaTheme2): CSSProperties { +function getContentStyle(): CSSProperties { return { - background: theme.colors.background.secondary, - color: theme.colors.text.primary, + background: 'rgba(230,0,0,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', }; } -function renderPanel(name: string, overrides: Partial, theme: GrafanaTheme2) { +function renderPanel(name: string, overrides: Partial) { const props: PanelChromeProps = { width: 400, height: 130, @@ -42,7 +44,7 @@ function renderPanel(name: string, overrides: Partial, theme: merge(props, overrides); - const contentStyle = getContentStyle(theme); + const contentStyle = getContentStyle(); return ( @@ -54,69 +56,75 @@ function renderPanel(name: string, overrides: Partial, theme: } export const Examples = () => { - const theme = useTheme2(); const [loading, setLoading] = useState(true); useInterval(() => setLoading(true), 5000); return ( -
- + + - {renderPanel('Default panel', {}, theme)} - {renderPanel('No padding', { padding: 'none' }, theme)} + {renderPanel('Default panel with error state indicator', { + title: 'Default title', + leftItems: [ + , + ], + })} + {renderPanel('No padding with error state indicator', { + padding: 'none', + title: 'Default title', + leftItems: [ + , + ], + })} - {renderPanel('No title', { title: '' }, theme)} - {renderPanel( - 'Very long title', - { title: 'Very long title that should get ellipsis when there is no more space' }, - theme - )} + {renderPanel('No title', { title: '' })} + {renderPanel('Very long title', { + title: 'Very long title that should get ellipsis when there is no more space', + })} -
- {renderPanel( - 'No title and loading indicator', - { - title: '', - leftItems: [ - setLoading(false)} - key="loading-indicator" - />, - ], - }, - theme - )} + {renderPanel('No title and loading indicator', { + title: '', + leftItems: [ + setLoading(false)} + key="loading-indicator" + />, + ], + })} - {renderPanel( - 'Very long title', - { - title: 'Very long title that should get ellipsis when there is no more space', - leftItems: [ - setLoading(false)} - key="loading-indicator" - />, - ], - }, - theme - )} + {renderPanel('Very long title', { + title: 'Very long title that should get ellipsis when there is no more space', + leftItems: [ + setLoading(false)} + key="loading-indicator" + />, + ], + })} -
+
); }; export const Basic: ComponentStory = (args: PanelChromeProps) => { - const theme = useTheme2(); - const contentStyle = getContentStyle(theme); + const contentStyle = getContentStyle(); return ( @@ -139,6 +147,59 @@ const ErrorIcon = [ const leftItems = { LoadingIcon, ErrorIcon, Default }; +const titleItems: PanelChromeInfoState[] = [ + { + icon: 'info', + tooltip: + 'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.', + }, + { + icon: 'external-link-alt', + tooltip: 'wearegoingonanadventure.openanewtab.maybe', + onClick: () => {}, + }, + { + icon: 'clock-nine', + tooltip: 'Time range: 2021-09-01 00:00:00 to 2021-09-01 00:00:00', + onClick: () => {}, + }, + { + icon: 'heart', + tooltip: 'Health of the panel', + }, +]; + +const menu = ( + + + + + + , + , + , + ]} + /> + , + , + , + , + , + ]} + /> + + + +); + Basic.argTypes = { leftItems: { options: Object.keys(leftItems), @@ -157,7 +218,9 @@ Basic.argTypes = { Basic.args = { width: 400, height: 200, - title: 'Title text', + title: 'Very long title that should get ellipsis when there is no more space', + titleItems, + menu, }; export default meta; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx new file mode 100644 index 00000000000..35508f8e3c7 --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -0,0 +1,107 @@ +import { screen, render } from '@testing-library/react'; +import React from 'react'; + +import { PanelChrome, PanelChromeProps } from './PanelChrome'; + +const setup = (propOverrides?: Partial) => { + const props: PanelChromeProps = { + width: 100, + height: 100, + children: (innerWidth, innerHeight) => { + return
Panel's Content
; + }, + }; + + Object.assign(props, propOverrides); + return render(); +}; + +it('renders an empty panel with required props only', () => { + setup(); + + expect(screen.getByText("Panel's Content")).toBeInTheDocument(); +}); + +it('renders an empty panel without padding', () => { + setup({ padding: 'none' }); + + expect(screen.getByText("Panel's Content").parentElement).toHaveStyle({ padding: '0px' }); +}); + +it('renders an empty panel with padding', () => { + setup({ padding: 'md' }); + + expect(screen.getByText("Panel's Content").style.getPropertyValue('height')).not.toBe('100px'); + expect(screen.getByText("Panel's Content").parentElement).not.toHaveStyle({ padding: '0px' }); +}); + +it('renders an empty panel without a header if no title or titleItems', () => { + setup(); + + expect(screen.queryByTestId('header-container')).not.toBeInTheDocument(); +}); + +it('renders panel with a header if prop title', () => { + setup({ title: 'Test Panel Header' }); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +it('renders panel with a header with title in place if prop title', () => { + setup({ title: 'Test Panel Header' }); + + expect(screen.getByText('Test Panel Header')).toBeInTheDocument(); +}); + +it('renders panel with a header if prop titleItems', () => { + setup({ + titleItems: [ + { + icon: 'info-circle', + tooltip: 'This is the panel description', + onClick: () => {}, + }, + ], + }); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +it('renders panel with a header with icons in place if prop titleItems', () => { + setup({ + titleItems: [ + { + icon: 'info-circle', + tooltip: 'This is the panel description', + onClick: () => {}, + }, + ], + }); + + expect(screen.getByTestId('title-items-container')).toBeInTheDocument(); +}); + +it('renders panel with a fixed header if prop hoverHeader is false', () => { + setup({ title: 'Test Panel Header', hoverHeader: false }); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +it('renders panel with a header if prop menu', () => { + setup({ menu:
Menu
}); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +it('renders panel with a show-on-hover menu icon if prop menu', () => { + setup({ menu:
Menu
}); + + expect(screen.getByTestId('menu-icon')).toBeInTheDocument(); + expect(screen.getByTestId('menu-icon')).not.toBeVisible(); +}); + +it.skip('renders states in the panel header if any given', () => {}); + +it.skip('renders leftItems in the panel header if any given when no states prop is given', () => {}); + +it.skip('renders states in the panel header if both leftItems and states are given', () => {}); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 02875a6c2ea..685261d1870 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -1,9 +1,25 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { CSSProperties, ReactNode } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, isIconName } from '@grafana/data'; import { useStyles2, useTheme2 } from '../../themes'; +import { IconName } from '../../types/icon'; +import { Dropdown } from '../Dropdown/Dropdown'; +import { Icon } from '../Icon/Icon'; +import { IconButton, IconButtonVariant } from '../IconButton/IconButton'; +import { PopoverContent, Tooltip } from '../Tooltip'; + +/** + * @internal + */ +export interface PanelChromeInfoState { + icon: IconName; + label?: string | ReactNode; + tooltip?: PopoverContent; + variant?: IconButtonVariant; + onClick?: () => void; +} /** * @internal @@ -11,10 +27,22 @@ import { useStyles2, useTheme2 } from '../../themes'; export interface PanelChromeProps { width: number; height: number; - title?: string; + children: (innerWidth: number, innerHeight: number) => ReactNode; padding?: PanelPadding; - leftItems?: React.ReactNode[]; // rightItems will be added later (actions links etc.) - children: (innerWidth: number, innerHeight: number) => React.ReactNode; + title?: string; + titleItems?: PanelChromeInfoState[]; + menu?: React.ReactElement; + /** dragClass, hoverHeader, loadingState, and states not yet implemented */ + // dragClass?: string; + hoverHeader?: boolean; + // loadingState?: LoadingState; + // states?: ReactNode[]; + /** @deprecated in favor of prop states + * which will serve the same purpose + * of showing the panel state in the top right corner + * of itself or its header + * */ + leftItems?: ReactNode[]; } /** @@ -26,33 +54,86 @@ export type PanelPadding = 'none' | 'md'; * @internal */ export const PanelChrome: React.FC = ({ - title = '', - children, width, height, + children, padding = 'md', + title = '', + titleItems = [], + menu, + // dragClass, + hoverHeader = false, + // loadingState, + // states = [], leftItems = [], }) => { const theme = useTheme2(); const styles = useStyles2(getStyles); - const headerHeight = getHeaderHeight(theme, title, leftItems); + const headerHeight = !hoverHeader ? getHeaderHeight(theme, title, leftItems) : 0; const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height); const headerStyles: CSSProperties = { height: headerHeight, }; - + const itemStyles: CSSProperties = { + minHeight: headerHeight, + minWidth: headerHeight, + }; const containerStyles: CSSProperties = { width, height }; + const handleMenuOpen = () => {}; + + const hasHeader = title || titleItems.length > 0 || menu; + return (
-
-
{title}
- {itemsRenderer(leftItems, (items) => { - return
{items}
; - })} -
+ {hasHeader && !hoverHeader && ( +
+ {title && ( +
+ {title} +
+ )} + + {titleItems.length > 0 && ( +
+ {titleItems + .filter((item) => isIconName(item.icon)) + .map((item, i) => ( +
+ {item.onClick ? ( + + ) : ( + + + + )} +
+ ))} +
+ )} + + {menu && ( + +
+ +
+
+ )} + + {leftItems.length > 0 && ( +
{itemsRenderer(leftItems, (item) => item)}
+ )} +
+ )} +
{children(innerWidth, innerHeight)}
@@ -95,39 +176,59 @@ const getStyles = (theme: GrafanaTheme2) => { const { padding, background, borderColor } = theme.components.panel; return { - container: css` - label: panel-container; - background-color: ${background}; - border: 1px solid ${borderColor}; - position: relative; - border-radius: 3px; - height: 100%; - display: flex; - flex-direction: column; - flex: 0 0 0; - `, - content: css` - label: panel-content; - width: 100%; - flex-grow: 1; - `, - header: css` - label: panel-header; - display: flex; - align-items: center; - `, - headerTitle: css` - label: panel-header; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - padding-left: ${theme.spacing(padding)}; - flex-grow: 1; - font-weight: ${theme.typography.fontWeightMedium}; - `, - leftItems: css` - display: flex; - padding-right: ${theme.spacing(padding)}; - `, + container: css({ + label: 'panel-container', + backgroundColor: background, + border: `1px solid ${borderColor}`, + position: 'relative', + borderRadius: '3px', + height: '100%', + display: 'flex', + flexDirection: 'column', + flex: '0 0 0', + + '&:focus-visible, &:hover': { + // only show menu icon on hover or focused panel + '.menu-icon': { + visibility: 'visible', + }, + }, + + '&:focus-visible': { + outline: `1px solid ${theme.colors.action.focus}`, + }, + }), + content: css({ + label: 'panel-content', + width: '100%', + contain: 'strict', + flexGrow: 1, + }), + headerContainer: css({ + label: 'panel-header', + display: 'flex', + alignItems: 'center', + padding: `0 ${theme.spacing(padding)}`, + }), + title: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + fontWeight: theme.typography.fontWeightMedium, + }), + items: css({ + display: 'flex', + }), + item: css({ + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center', + }), + menuItem: css({ + visibility: 'hidden', + }), + rightAligned: css({ + marginLeft: 'auto', + }), }; }; diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts index fadc4db2ece..5672a56ad23 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts @@ -38,14 +38,14 @@ describe('sharedSingleStatMigrationHandler', () => { sharedSingleStatMigrationHandler(panel as any); expect((panel as any).fieldConfig).toMatchInlineSnapshot(` - Object { - "defaults": Object { - "color": Object { + { + "defaults": { + "color": { "mode": "thresholds", }, "decimals": 5, - "mappings": Array [ - Object { + "mappings": [ + { "text": "OK", "type": 1, "value": "1", @@ -53,20 +53,20 @@ describe('sharedSingleStatMigrationHandler', () => { ], "max": 100, "min": 10, - "thresholds": Object { + "thresholds": { "mode": "absolute", - "steps": Array [ - Object { + "steps": [ + { "color": "green", "index": 0, "value": -Infinity, }, - Object { + { "color": "orange", "index": 1, "value": 40, }, - Object { + { "color": "red", "index": 2, "value": 80, @@ -75,7 +75,7 @@ describe('sharedSingleStatMigrationHandler', () => { }, "unit": "watt", }, - "overrides": Array [], + "overrides": [], } `); }); @@ -110,12 +110,12 @@ describe('sharedSingleStatMigrationHandler', () => { sharedSingleStatMigrationHandler(panel as any); expect((panel as any).fieldConfig).toMatchInlineSnapshot(` - Object { - "defaults": Object { + { + "defaults": { "mappings": undefined, "thresholds": undefined, }, - "overrides": Array [], + "overrides": [], } `); }); @@ -145,14 +145,14 @@ describe('sharedSingleStatMigrationHandler', () => { sharedSingleStatMigrationHandler(panel as any); expect((panel as any).fieldConfig).toMatchInlineSnapshot(` - Object { - "defaults": Object { + { + "defaults": { "mappings": undefined, "max": 100, "min": 0, "thresholds": undefined, }, - "overrides": Array [], + "overrides": [], } `); }); diff --git a/packages/grafana-ui/src/components/Table/RowExpander.tsx b/packages/grafana-ui/src/components/Table/RowExpander.tsx new file mode 100644 index 00000000000..1e4f5a52cc0 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/RowExpander.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; +import { Row } from 'react-table'; + +import { useStyles2 } from '../../themes'; +import { Icon } from '../Icon/Icon'; + +import { getTableStyles } from './styles'; + +export interface Props { + row: Row; + expandedIndexes: Set; + setExpandedIndexes: (indexes: Set) => void; +} + +export const RowExpander: FC = ({ row, expandedIndexes, setExpandedIndexes }) => { + const tableStyles = useStyles2(getTableStyles); + const isExpanded = expandedIndexes.has(row.index); + // Use Cell to render an expander for each row. + // We can use the getToggleRowExpandedProps prop-getter + // to build the expander. + return ( +
{ + const newExpandedIndexes = new Set(expandedIndexes); + if (isExpanded) { + newExpandedIndexes.delete(row.index); + } else { + newExpandedIndexes.add(row.index); + } + setExpandedIndexes(newExpandedIndexes); + }} + > + +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Table/Table.mdx b/packages/grafana-ui/src/components/Table/Table.mdx index 685ea7f424b..44ca807b86f 100644 --- a/packages/grafana-ui/src/components/Table/Table.mdx +++ b/packages/grafana-ui/src/components/Table/Table.mdx @@ -5,6 +5,14 @@ import { Table } from './Table'; Used for displaying tabular data +## Sub-tables + +Sub-tables are supported through the usage of the prop `subData` Dataframe array. +The frames are linked to each row using the following custom properties under `dataframe.meta.custom` + +- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component) +- **noHeader**: boolean - Sets the noHeader of each sub-table + ## Usage diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index 75a7cd65fd0..a300ecb99a2 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -36,7 +36,7 @@ const meta: ComponentMeta = { args: { width: 700, height: 500, - columnMinWidth: 150, + columnMinWidth: 130, }, }; @@ -98,6 +98,61 @@ function buildData(theme: GrafanaTheme2, config: Record): D return prepDataForStorybook([data], theme)[0]; } +function buildSubTablesData(theme: GrafanaTheme2, config: Record): DataFrame[] { + const frames: DataFrame[] = []; + + for (let i = 0; i < 1000; i++) { + const data = new MutableDataFrame({ + meta: { + custom: { + parentRowIndex: i, + }, + }, + fields: [ + { name: 'Time', type: FieldType.time, values: [] }, // The time field + { + name: 'Quantity', + type: FieldType.number, + values: [], + config: { + decimals: 0, + custom: { + align: 'center', + }, + }, + }, + { name: 'Quality', type: FieldType.string, values: [] }, // The time field + { + name: 'Progress', + type: FieldType.number, + values: [], + config: { + unit: 'percent', + min: 0, + max: 100, + }, + }, + ], + }); + + for (const field of data.fields) { + field.config = merge(field.config, config[field.name]); + } + + for (let i = 0; i < Math.random() * 4 + 1; i++) { + data.appendRow([ + new Date().getTime(), + Math.random() * 2, + Math.random() > 0.7 ? 'Good' : 'Bad', + Math.random() * 100, + ]); + } + + frames.push(data); + } + return prepDataForStorybook(frames, theme); +} + function buildFooterData(data: DataFrame): FooterItem[] { const values = data.fields[3].values.toArray(); const valueSum = values.reduce((prev, curr) => { @@ -195,4 +250,23 @@ Pagination.args = { enablePagination: true, }; +export const SubTables: ComponentStory = (args) => { + const theme = useTheme2(); + const data = buildData(theme, {}); + const subData = buildSubTablesData(theme, { + Progress: { + custom: { + displayMode: 'gradient-gauge', + }, + thresholds: defaultThresholds, + }, + }); + + return ( +
+ + + ); +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index bd11b19bde1..b6ce1f0d003 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -398,4 +398,54 @@ describe('Table', () => { expect(() => screen.getByTestId('table-footer')).toThrow('Unable to find an element'); }); }); + + describe('when mounted with data and sub-data', () => { + it('then correct rows should be rendered and new table is rendered when expander is clicked', () => { + getTestContext({ + subData: new Array(getDefaultDataFrame().length).fill(0).map((i) => + toDataFrame({ + name: 'A', + fields: [ + { + name: 'number' + i, + type: FieldType.number, + values: [i, i, i], + config: { + custom: { + filterable: true, + }, + }, + }, + ], + meta: { + custom: { + parentRowIndex: i, + }, + }, + }) + ), + }); + expect(getTable()).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(4); + expect(getColumnHeader(/time/)).toBeInTheDocument(); + expect(getColumnHeader(/temperature/)).toBeInTheDocument(); + expect(getColumnHeader(/img/)).toBeInTheDocument(); + + const rows = within(getTable()).getAllByRole('row'); + expect(rows).toHaveLength(5); + expect(getRowsData(rows)).toEqual([ + { time: '2021-01-01 00:00:00', temperature: '10', link: '10' }, + { time: '2021-01-01 03:00:00', temperature: 'NaN', link: 'NaN' }, + { time: '2021-01-01 01:00:00', temperature: '11', link: '11' }, + { time: '2021-01-01 02:00:00', temperature: '12', link: '12' }, + ]); + + within(rows[1]).getByLabelText('Open trace').click(); + const rowsAfterClick = within(getTable()).getAllByRole('row'); + expect(within(rowsAfterClick[1]).getByRole('table')).toBeInTheDocument(); + expect(within(rowsAfterClick[1]).getByText(/number0/)).toBeInTheDocument(); + + expect(within(rowsAfterClick[2]).queryByRole('table')).toBeNull(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index fe69cf86ba3..dc3103067ec 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -9,7 +9,8 @@ import { useSortBy, useTable, } from 'react-table'; -import { FixedSizeList } from 'react-window'; +import usePrevious from 'react-use/lib/usePrevious'; +import { VariableSizeList } from 'react-window'; import { DataFrame, getFieldDisplayName, Field } from '@grafana/data'; @@ -30,7 +31,14 @@ import { TableFooterCalc, GrafanaTableColumn, } from './types'; -import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils'; +import { + getColumns, + sortCaseInsensitive, + sortNumber, + getFooterItems, + createFooterCalculationValues, + EXPANDER_WIDTH, +} from './utils'; const COLUMN_MIN_WIDTH = 150; @@ -51,6 +59,8 @@ export interface Props { footerOptions?: TableFooterCalc; footerValues?: FooterItem[]; enablePagination?: boolean; + /** @alpha */ + subData?: DataFrame[]; } function useTableStateReducer({ onColumnResize, onSortByChange, data }: Props) { @@ -121,6 +131,7 @@ export const Table = memo((props: Props) => { const { ariaLabel, data, + subData, height, onCellFilterAdded, width, @@ -134,13 +145,15 @@ export const Table = memo((props: Props) => { enablePagination, } = props; - const listRef = useRef(null); + const listRef = useRef(null); const tableDivRef = useRef(null); - const fixedSizeListScrollbarRef = useRef(null); + const variableSizeListScrollbarRef = useRef(null); const tableStyles = useStyles2(getTableStyles); const theme = useTheme2(); const headerHeight = noHeader ? 0 : tableStyles.cellHeight; const [footerItems, setFooterItems] = useState(footerValues); + const [expandedIndexes, setExpandedIndexes] = useState>(new Set()); + const prevExpandedIndexes = usePrevious(expandedIndexes); const footerHeight = useMemo(() => { const EXTENDED_ROW_HEIGHT = 33; @@ -177,8 +190,8 @@ export const Table = memo((props: Props) => { // React-table column definitions const memoizedColumns = useMemo( - () => getColumns(data, width, columnMinWidth, footerItems), - [data, width, columnMinWidth, footerItems] + () => getColumns(data, width, columnMinWidth, expandedIndexes, setExpandedIndexes, !!subData?.length, footerItems), + [data, width, columnMinWidth, footerItems, subData, expandedIndexes] ); // Internal react table state reducer @@ -260,14 +273,23 @@ export const Table = memo((props: Props) => { setPageSize(pageSize); }, [pageSize, setPageSize]); + useEffect(() => { + // react-table caches the height of cells so we need to reset them when expanding/collapsing rows + // We need to take the minimum of the current expanded indexes and the previous expandedIndexes array to account + // for collapsed rows, since they disappear from expandedIndexes but still keep their expanded height + listRef.current?.resetAfterIndex( + Math.min(...Array.from(expandedIndexes), ...(prevExpandedIndexes ? Array.from(prevExpandedIndexes) : [])) + ); + }, [expandedIndexes, prevExpandedIndexes]); + useEffect(() => { // To have the custom vertical scrollbar always visible (https://github.com/grafana/grafana/issues/52136), - // we need to bring the element from the FixedSizeList scope to the outer Table container scope, - // because the FixedSizeList scope has overflow. By moving scrollbar to container scope we will have + // we need to bring the element from the VariableSizeList scope to the outer Table container scope, + // because the VariableSizeList scope has overflow. By moving scrollbar to container scope we will have // it always visible since the entire width is in view. - // Select the scrollbar element from the FixedSizeList scope - const listVerticalScrollbarHTML = (fixedSizeListScrollbarRef.current as HTMLDivElement)?.querySelector( + // Select the scrollbar element from the VariableSizeList scope + const listVerticalScrollbarHTML = (variableSizeListScrollbarRef.current as HTMLDivElement)?.querySelector( '.track-vertical' ); @@ -283,6 +305,36 @@ export const Table = memo((props: Props) => { } }); + const renderSubTable = React.useCallback( + (rowIndex: number) => { + if (expandedIndexes.has(rowIndex)) { + const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex); + if (rowSubData) { + const noHeader = !!rowSubData.meta?.custom?.noHeader; + const subTableStyle: CSSProperties = { + height: tableStyles.rowHeight * (rowSubData.length + (noHeader ? 0 : 1)), // account for the header with + 1 + background: theme.colors.emphasize(theme.colors.background.primary, 0.015), + paddingLeft: EXPANDER_WIDTH, + position: 'absolute', + bottom: 0, + }; + return ( +
+
+ + ); + } + } + return null; + }, + [expandedIndexes, subData, tableStyles.rowHeight, theme.colors, width] + ); + const RenderRow = React.useCallback( ({ index: rowIndex, style }: { index: number; style: CSSProperties }) => { let row = rows[rowIndex]; @@ -290,8 +342,11 @@ export const Table = memo((props: Props) => { row = page[rowIndex]; } prepareRow(row); + return (
+ {/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} + {renderSubTable(rowIndex)} {row.cells.map((cell: Cell, index: number) => ( {
); }, - [onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles] + [onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles, renderSubTable] ); const onNavigate = useCallback( @@ -344,6 +399,17 @@ export const Table = memo((props: Props) => { ); } + const getItemSize = (index: number): number => { + if (expandedIndexes.has(index)) { + const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index); + if (rowSubData) { + const noHeader = !!rowSubData.meta?.custom?.noHeader; + return tableStyles.rowHeight * (rowSubData.length + 1 + (noHeader ? 0 : 1)); // account for the header and the row data with + 1 + 1 + } + } + return tableStyles.rowHeight; + }; + const handleScroll: React.UIEventHandler = (event) => { const { scrollTop } = event.target as HTMLDivElement; @@ -358,18 +424,18 @@ export const Table = memo((props: Props) => {
{!noHeader && } {itemCount > 0 ? ( -
+
- {RenderRow} - +
) : ( diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 5d08f95d6a6..0f769c4c933 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -11,6 +11,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => { const lineHeight = theme.typography.body.lineHeight; const bodyFontSize = 14; const cellHeight = cellPadding * 2 + bodyFontSize * lineHeight; + const rowHeight = cellHeight + 2; const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03); const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => { @@ -36,7 +37,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => { label: ${overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow'}; padding: ${cellPadding}px; width: 100%; - height: 100%; + height: ${rowHeight}px; display: flex; align-items: center; border-right: 1px solid ${borderColor}; @@ -95,7 +96,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => { buildCellContainerStyle, cellPadding, cellHeightInner: bodyFontSize * lineHeight, - rowHeight: cellHeight + 2, + rowHeight, table: css` height: 100%; width: 100%; @@ -253,6 +254,13 @@ export const getTableStyles = (theme: GrafanaTheme2) => { justify-content: center; width: 100%; `, + expanderCell: css` + display: flex; + flex-direction: column; + justify-content: center; + height: ${rowHeight}px; + cursor: pointer; + `, }; }; diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.tsx similarity index 92% rename from packages/grafana-ui/src/components/Table/utils.test.ts rename to packages/grafana-ui/src/components/Table/utils.test.tsx index 1dad733f3ca..293c3511cfa 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.tsx @@ -1,3 +1,5 @@ +import { Row } from 'react-table'; + import { ArrayVector, Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data'; import { @@ -44,21 +46,29 @@ function getData() { describe('Table utils', () => { describe('getColumns', () => { it('Should build columns from DataFrame', () => { - const columns = getColumns(getData(), 1000, 120); + const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false); expect(columns[0].Header).toBe('Time'); expect(columns[1].Header).toBe('Value'); }); it('Should distribute width and use field config width', () => { - const columns = getColumns(getData(), 1000, 120); + const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false); expect(columns[0].width).toBe(450); expect(columns[1].width).toBe(100); }); + it('Should distribute width and use field config width with expander enabled', () => { + const columns = getColumns(getData(), 1000, 120, new Set(), () => null, true); + + expect(columns[0].width).toBe(50); // expander column + expect(columns[1].width).toBe(425); + expect(columns[2].width).toBe(100); + }); + it('Should set field on columns', () => { - const columns = getColumns(getData(), 1000, 120); + const columns = getColumns(getData(), 1000, 120, new Set(), () => null, false); expect(columns[0].field.name).toBe('Time'); expect(columns[1].field.name).toBe('Value'); @@ -82,8 +92,8 @@ describe('Table utils', () => { describe('filterByValue', () => { describe('happy path', () => { - const field: any = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) }; - const rows: any = [ + const field = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) } as unknown as Field; + const rows = [ { index: 0, values: { 0: 'a' } }, { index: 1, values: { 0: 'aa' } }, { index: 2, values: { 0: 'ab' } }, @@ -91,7 +101,7 @@ describe('Table utils', () => { { index: 4, values: { 0: 'ba' } }, { index: 5, values: { 0: 'bb' } }, { index: 6, values: { 0: 'c' } }, - ]; + ] as unknown as Row[]; const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }]; const result = filterByValue(field)(rows, '0', filterValues); @@ -106,8 +116,8 @@ describe('Table utils', () => { describe('fast exit cases', () => { describe('no rows', () => { it('should return empty array', () => { - const field: any = { values: new ArrayVector(['a']) }; - const rows: any = []; + const field = { values: new ArrayVector(['a']) } as unknown as Field; + const rows: Row[] = []; const filterValues = [{ value: 'a' }]; const result = filterByValue(field)(rows, '', filterValues); @@ -118,8 +128,8 @@ describe('Table utils', () => { describe('no filterValues', () => { it('should return rows', () => { - const field: any = { values: new ArrayVector(['a']) }; - const rows: any = [{}]; + const field = { values: new ArrayVector(['a']) } as unknown as Field; + const rows = [{}] as Row[]; const filterValues = undefined; const result = filterByValue(field)(rows, '', filterValues); @@ -131,7 +141,7 @@ describe('Table utils', () => { describe('no field', () => { it('should return rows', () => { const field = undefined; - const rows: any = [{}]; + const rows = [{}] as Row[]; const filterValues = [{ value: 'a' }]; const result = filterByValue(field)(rows, '', filterValues); @@ -142,12 +152,12 @@ describe('Table utils', () => { describe('missing id in values', () => { it('should return rows', () => { - const field: any = { values: new ArrayVector(['a', 'b', 'c']) }; - const rows: any = [ + const field = { values: new ArrayVector(['a', 'b', 'c']) } as unknown as Field; + const rows = [ { index: 0, values: { 0: 'a' } }, { index: 1, values: { 0: 'b' } }, { index: 2, values: { 0: 'c' } }, - ]; + ] as unknown as Row[]; const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }]; const result = filterByValue(field)(rows, '1', filterValues); @@ -188,7 +198,7 @@ describe('Table utils', () => { text: '1.0', }), }; - const rows: any[] = []; + const rows = [] as Row[]; const result = calculateUniqueFieldValues(rows, field); diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.tsx similarity index 89% rename from packages/grafana-ui/src/components/Table/utils.ts rename to packages/grafana-ui/src/components/Table/utils.tsx index ce988462f05..fdce74d6853 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.tsx @@ -1,6 +1,7 @@ import { Property } from 'csstype'; import { clone } from 'lodash'; import memoizeOne from 'memoize-one'; +import React from 'react'; import { Row } from 'react-table'; import { @@ -23,6 +24,7 @@ import { getFooterValue } from './FooterRow'; import { GeoCell } from './GeoCell'; import { ImageCell } from './ImageCell'; import { JSONViewCell } from './JSONViewCell'; +import { RowExpander } from './RowExpander'; import { CellComponent, TableCellDisplayMode, @@ -32,6 +34,8 @@ import { TableFooterCalc, } from './types'; +export const EXPANDER_WIDTH = 50; + export function getTextAlign(field?: Field): Property.JustifyContent { if (!field) { return 'flex-start'; @@ -61,11 +65,37 @@ export function getColumns( data: DataFrame, availableWidth: number, columnMinWidth: number, + expandedIndexes: Set, + setExpandedIndexes: (indexes: Set) => void, + expander: boolean, footerValues?: FooterItem[] ): GrafanaTableColumn[] { - const columns: GrafanaTableColumn[] = []; + const columns: GrafanaTableColumn[] = expander + ? [ + { + // Make an expander cell + Header: () => null, // No header + id: 'expander', // It needs an ID + Cell: ({ row }) => { + return ; + }, + width: EXPANDER_WIDTH, + minWidth: EXPANDER_WIDTH, + filter: (rows: Row[], id: string, filterValues?: SelectableValue[]) => { + return []; + }, + justifyContent: 'left', + field: data.fields[0], + sortType: 'basic', + }, + ] + : []; let fieldCountWithoutWidth = 0; + if (expander) { + availableWidth -= EXPANDER_WIDTH; + } + for (const [fieldIndex, field] of data.fields.entries()) { const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 28cc4c7e728..d27276ef2d6 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -65,8 +65,8 @@ export class UPlotChart extends Component { }); const config: Options = { - width: this.props.width, - height: this.props.height, + width: Math.floor(this.props.width), + height: Math.floor(this.props.height), ...this.props.config.getConfig(), }; @@ -93,8 +93,8 @@ export class UPlotChart extends Component { if (!sameDims(prevProps, this.props)) { plot?.setSize({ - width: this.props.width, - height: this.props.height, + width: Math.floor(this.props.width), + height: Math.floor(this.props.height), }); } else if (!sameConfig(prevProps, this.props)) { this.reinitPlot(); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index a4714bddd7b..1e28fd6c97b 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -21,41 +21,41 @@ describe('UPlotConfigBuilder', () => { it('builds default config', () => { const builder = new UPlotConfigBuilder(); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object {}, + "scales": {}, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -84,46 +84,46 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object { - "scale-x": Object { + "scales": { + "scale-x": { "auto": false, "dir": 1, "ori": 0, "range": [Function], "time": true, }, - "scale-y": Object { + "scale-y": { "asinh": undefined, "auto": true, "dir": 1, @@ -135,8 +135,8 @@ describe('UPlotConfigBuilder', () => { }, }, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -177,39 +177,39 @@ describe('UPlotConfigBuilder', () => { distribution: ScaleDistribution.Linear, }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object { - "scale-y": Object { + "scales": { + "scale-y": { "asinh": undefined, "auto": true, "dir": 1, @@ -221,8 +221,8 @@ describe('UPlotConfigBuilder', () => { }, }, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -243,39 +243,39 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object { - "scale-y": Object { + "scales": { + "scale-y": { "asinh": undefined, "auto": true, "dir": 1, @@ -287,8 +287,8 @@ describe('UPlotConfigBuilder', () => { }, }, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -310,39 +310,39 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object { - "scale-y": Object { + "scales": { + "scale-y": { "asinh": undefined, "auto": true, "dir": 1, @@ -354,8 +354,8 @@ describe('UPlotConfigBuilder', () => { }, }, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -411,19 +411,19 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [ - Object { + { + "axes": [ + { "filter": undefined, - "font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "font": "12px "Roboto", "Helvetica", "Arial", sans-serif", "gap": 5, - "grid": Object { + "grid": { "show": false, "stroke": "rgba(240, 250, 255, 0.09)", "width": 1, }, "label": "test label", - "labelFont": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "labelFont": "12px "Roboto", "Helvetica", "Arial", sans-serif", "labelGap": 8, "labelSize": 20, "rotate": undefined, @@ -434,49 +434,49 @@ describe('UPlotConfigBuilder', () => { "space": [Function], "splits": undefined, "stroke": "rgb(204, 204, 220)", - "ticks": Object { + "ticks": { "show": true, "size": 4, "stroke": "rgba(240, 250, 255, 0.09)", "width": 1, }, "timeZone": "browser", - "values": Array [], + "values": [], }, ], - "cursor": Object { - "drag": Object { + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object {}, + "scales": {}, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, ], @@ -573,49 +573,49 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "cursor": Object { - "drag": Object { + { + "axes": [], + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object {}, + "scales": {}, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, - Object { + { "facets": undefined, "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#0000ff", "filter": undefined, "size": 5, @@ -685,65 +685,65 @@ describe('UPlotConfigBuilder', () => { }); expect(builder.getConfig()).toMatchInlineSnapshot(` - Object { - "axes": Array [], - "bands": Array [ - Object { + { + "axes": [], + "bands": [ + { "fill": "red", - "series": Array [ + "series": [ 3, 2, ], }, - Object { + { "fill": "blue", - "series": Array [ + "series": [ 2, 1, ], }, ], - "cursor": Object { - "drag": Object { + "cursor": { + "drag": { "setScale": false, }, - "focus": Object { + "focus": { "prox": 30, }, - "points": Object { + "points": { "fill": [Function], "size": [Function], "stroke": [Function], "width": [Function], }, }, - "focus": Object { + "focus": { "alpha": 1, }, - "hooks": Object {}, - "legend": Object { + "hooks": {}, + "legend": { "show": false, }, "mode": 1, "ms": 1, - "padding": Array [ + "padding": [ [Function], [Function], [Function], [Function], ], - "scales": Object {}, + "scales": {}, "select": undefined, - "series": Array [ - Object { + "series": [ + { "value": [Function], }, - Object { + { "facets": undefined, "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#0000ff", "filter": undefined, "size": undefined, @@ -757,12 +757,12 @@ describe('UPlotConfigBuilder', () => { "value": [Function], "width": 1, }, - Object { + { "facets": undefined, "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#00ff00", "filter": undefined, "size": 5, @@ -776,12 +776,12 @@ describe('UPlotConfigBuilder', () => { "value": [Function], "width": 1, }, - Object { + { "facets": undefined, "fill": [Function], "gaps": [Function], "paths": [Function], - "points": Object { + "points": { "fill": "#ff0000", "filter": undefined, "size": 5, diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index b09afd295fc..20b3012c259 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -31,23 +31,23 @@ describe('preparePlotData2', () => { it('creates array from DataFrame', () => { expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 20, 20, 20, @@ -67,23 +67,23 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ -20, -20, -20, @@ -108,62 +108,62 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, undefined, ], - Array [ + [ -10, 20, 10, 30, ], - Array [ + [ 10, 10, 10, null, ], - Array [ + [ null, -20, -20, -20, ], - Array [ + [ -20, -20, -20, null, ], - Array [ + [ -20, null, -20, -20, ], - Array [ + [ 10, 10, 10, undefined, ], - Array [ + [ undefined, -20, -20, -20, ], - Array [ + [ -20, -20, -20, undefined, ], - Array [ + [ -20, undefined, -20, @@ -182,23 +182,23 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, undefined, undefined, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 20, 20, 20, @@ -230,23 +230,23 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 20, 20, 20, @@ -277,23 +277,23 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 30, 30, 30, @@ -333,28 +333,28 @@ describe('preparePlotData2', () => { ], }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ -30, 0, -10, ], - Array [ + [ -40, -10, -20, @@ -401,38 +401,38 @@ describe('preparePlotData2', () => { }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 30, 30, 30, ], - Array [ + [ 1, 2, 3, ], - Array [ + [ 2, 4, 6, ], - Array [ + [ 3, 6, 9, @@ -476,33 +476,33 @@ describe('preparePlotData2', () => { }); expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 9997, 9998, 9999, ], - Array [ + [ -10, 20, 10, ], - Array [ + [ 10, 10, 10, ], - Array [ + [ 1, 2, 3, ], - Array [ + [ 1, 2, 3, ], - Array [ + [ 2, 4, 6, @@ -615,8 +615,8 @@ describe('preparePlotData2', () => { })!; expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ 1639455966000, 1639629625944, 1639803285888, @@ -636,7 +636,7 @@ describe('preparePlotData2', () => { 1642234525104, 1642408185048, ], - Array [ + [ 0, 0, 0, @@ -656,7 +656,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 0, 0, 2500, @@ -676,7 +676,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 0, 0, 30500, @@ -696,7 +696,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 0, 0, 30500, @@ -716,7 +716,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 0, 0, 35500, @@ -736,7 +736,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 47845, @@ -756,7 +756,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 47845, @@ -776,7 +776,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 47845, @@ -796,7 +796,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 47845, @@ -816,7 +816,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 48095, @@ -836,7 +836,7 @@ describe('preparePlotData2', () => { 0, 0, ], - Array [ + [ 122, 123, 48095, @@ -856,7 +856,7 @@ describe('preparePlotData2', () => { 1520, 665.35, ], - Array [ + [ 122, 123, 48095, @@ -876,7 +876,7 @@ describe('preparePlotData2', () => { 1520, 665.35, ], - Array [ + [ 122, 123, 48095, @@ -896,7 +896,7 @@ describe('preparePlotData2', () => { 5120, 665.35, ], - Array [ + [ 122, 123, 48095, @@ -916,7 +916,7 @@ describe('preparePlotData2', () => { 6795, 665.35, ], - Array [ + [ 122, 123, 48095, @@ -936,7 +936,7 @@ describe('preparePlotData2', () => { 6795, 665.35, ], - Array [ + [ 122, 123, 48095, @@ -956,7 +956,7 @@ describe('preparePlotData2', () => { 62118.84, 14496.31, ], - Array [ + [ 122, 123, 48095, @@ -976,7 +976,7 @@ describe('preparePlotData2', () => { 62118.84, 14496.31, ], - Array [ + [ 122, 123, 48095, @@ -1020,16 +1020,16 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": 1, - "series": Array [ + "series": [ 1, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 2, ], }, @@ -1068,17 +1068,17 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": -1, - "series": Array [ + "series": [ 1, 4, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 2, 3, ], @@ -1115,10 +1115,10 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": 1, - "series": Array [ + "series": [ 1, 2, 3, @@ -1186,34 +1186,34 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": 1, - "series": Array [ + "series": [ 1, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 2, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 3, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 4, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 5, ], }, @@ -1239,16 +1239,16 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": 1, - "series": Array [ + "series": [ 1, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 2, ], }, @@ -1296,35 +1296,35 @@ describe('auto stacking groups', () => { }); expect(getStackingGroups(df)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "dir": 1, - "series": Array [ + "series": [ 1, 2, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 3, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 4, ], }, - Object { + { "dir": 1, - "series": Array [ + "series": [ 5, ], }, - Object { + { "dir": -1, - "series": Array [ + "series": [ 6, ], }, diff --git a/packages/grafana-ui/src/utils/dataLinks.test.ts b/packages/grafana-ui/src/utils/dataLinks.test.ts new file mode 100644 index 00000000000..a4f5ae1a61b --- /dev/null +++ b/packages/grafana-ui/src/utils/dataLinks.test.ts @@ -0,0 +1,31 @@ +import { isCompactUrl } from './dataLinks'; + +describe('Datalinks', () => { + it('isCompactUrl matches compact URL with segments', () => { + expect( + isCompactUrl( + 'http://localhost:3000/explore?orgId=1&left=[%22now-1h%22,%22now%22,%22gdev-loki%22,{%22expr%22:%22{place=%22luna%22}%22,%22refId%22:%22A%22}]' + ) + ).toEqual(true); + }); + + it('isCompactUrl matches compact URL without segments', () => { + expect(isCompactUrl('http://localhost:3000/explore?orgId=1&left=[%22now-1h%22,%22now%22,%22gdev-loki%22]')).toEqual( + true + ); + }); + + it('isCompactUrl matches compact URL with right pane', () => { + expect( + isCompactUrl('http://localhost:3000/explore?orgId=1&right=[%22now-1h%22,%22now%22,%22gdev-loki%22]') + ).toEqual(true); + }); + + it('isCompactUrl does not match non-compact url', () => { + expect( + isCompactUrl( + 'http://localhost:3000/explore?orgId=1&left={"datasource":"test[datasource]","queries":[{"refId":"A","datasource":{"type":"prometheus","uid":"gdev-prometheus"}}],"range":{"from":"now-1h","to":"now"}}' + ) + ).toEqual(false); + }); +}); diff --git a/packages/grafana-ui/src/utils/dataLinks.ts b/packages/grafana-ui/src/utils/dataLinks.ts index 5d2a6db23fd..e5111f90851 100644 --- a/packages/grafana-ui/src/utils/dataLinks.ts +++ b/packages/grafana-ui/src/utils/dataLinks.ts @@ -18,3 +18,8 @@ export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItem }; }); }; + +export const isCompactUrl = (url: string) => { + const compactExploreUrlRegex = /\/explore\?.*&(left|right)=\[(.*\,){2,}(.*){1}\]/; + return compactExploreUrlRegex.test(url); +}; diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 1fc08e847db..151c54674f4 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -16,7 +16,7 @@ "@types/classnames": "^2.2.7", "@types/deep-freeze": "^0.1.1", "@types/hoist-non-react-statics": "^3.3.1", - "@types/jest": "28.1.6", + "@types/jest": "29.2.3", "@types/lodash": "4.14.187", "@types/prop-types": "15.7.5", "@types/react": "17.0.42", diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap index 3c12d1d9a8f..891f5a78940 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap @@ -3,14 +3,14 @@ exports[` shallow tests matches a snapshot 1`] = `
shallow tests matches a snapshot 1`] = `
shallow tests matches a snapshot 1`] = ` data-item-key="0" key="0" style={ - Object { + { "height": 2, "position": "absolute", "top": 0, @@ -44,7 +44,7 @@ exports[` shallow tests matches a snapshot 1`] = ` data-item-key="1" key="1" style={ - Object { + { "height": 4, "position": "absolute", "top": 2, @@ -57,7 +57,7 @@ exports[` shallow tests matches a snapshot 1`] = ` data-item-key="2" key="2" style={ - Object { + { "height": 6, "position": "absolute", "top": 6, @@ -70,7 +70,7 @@ exports[` shallow tests matches a snapshot 1`] = ` data-item-key="3" key="3" style={ - Object { + { "height": 8, "position": "absolute", "top": 12, @@ -83,7 +83,7 @@ exports[` shallow tests matches a snapshot 1`] = ` data-item-key="4" key="4" style={ - Object { + { "height": 10, "position": "absolute", "top": 20, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx index 57bd8f51c92..e7587f0a563 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx @@ -215,10 +215,20 @@ export default class ListView extends React.Component { } } - componentDidUpdate() { + componentDidUpdate(prevProps: TListViewProps) { if (this._itemHolderElm) { this._scanItemHeights(); } + // When windowScroller is set to false, we can continue to handle scrollElement + if (this.props.windowScroller) { + return; + } + // check if the scrollElement changes and update its scroll listener + if (prevProps.scrollElement !== this.props.scrollElement) { + prevProps.scrollElement?.removeEventListener('scroll', this._onScroll); + this._wrapperElm = this.props.scrollElement; + this._wrapperElm?.addEventListener('scroll', this._onScroll); + } } componentWillUnmount() { diff --git a/packages/jaeger-ui-components/src/Tween.test.js b/packages/jaeger-ui-components/src/Tween.test.ts similarity index 96% rename from packages/jaeger-ui-components/src/Tween.test.js rename to packages/jaeger-ui-components/src/Tween.test.ts index 0b0d1e73893..fbfd014924d 100644 --- a/packages/jaeger-ui-components/src/Tween.test.js +++ b/packages/jaeger-ui-components/src/Tween.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Tween from './Tween'; +import Tween, { TweenState } from './Tween'; describe('Tween', () => { const oldNow = Date.now; @@ -24,8 +24,10 @@ describe('Tween', () => { const baseOptions = { duration: 10, from: 0, to: 1 }; + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + Date.now = nowFn; - window.setTimeout = setTimeoutFn; window.requestAnimationFrame = rafFn; beforeEach(() => { @@ -60,7 +62,7 @@ describe('Tween', () => { it('schedules setTimeout if there is a delay', () => { const delay = 10; const tween = new Tween({ ...baseOptions, delay, onUpdate: jest.fn() }); - expect(setTimeoutFn).lastCalledWith(tween._frameCallback, delay); + expect(setTimeout).lastCalledWith(tween._frameCallback, delay); }); it('schedules animation frame if there is not a delay', () => { @@ -110,7 +112,7 @@ describe('Tween', () => { describe('_frameCallback', () => { it('freezes the callback argument', () => { - let current; + let current: TweenState | undefined; const fn = jest.fn((_current) => { current = _current; }); @@ -119,7 +121,7 @@ describe('Tween', () => { expect(current).toBeDefined(); const copy = { ...current }; try { - current.done = !current.done; + current!.done = !current!.done; // eslint-disable-next-line no-empty } catch (_) {} expect(current).toEqual(copy); diff --git a/packages/jaeger-ui-components/src/Tween.tsx b/packages/jaeger-ui-components/src/Tween.tsx index 5a49fb28ec6..c9c082a7379 100644 --- a/packages/jaeger-ui-components/src/Tween.tsx +++ b/packages/jaeger-ui-components/src/Tween.tsx @@ -16,7 +16,7 @@ import ease from 'tween-functions'; import { TNil } from './types'; -interface TweenState { +export interface TweenState { done: boolean; value: number; } diff --git a/packages/jaeger-ui-components/src/common/CopyIcon.test.js b/packages/jaeger-ui-components/src/common/CopyIcon.test.tsx similarity index 97% rename from packages/jaeger-ui-components/src/common/CopyIcon.test.js rename to packages/jaeger-ui-components/src/common/CopyIcon.test.tsx index b06ac040043..9f0101501f1 100644 --- a/packages/jaeger-ui-components/src/common/CopyIcon.test.js +++ b/packages/jaeger-ui-components/src/common/CopyIcon.test.tsx @@ -26,7 +26,7 @@ describe('', () => { copyText: 'copyTextValue', tooltipTitle: 'tooltipTitleValue', }; - let copySpy; + let copySpy: jest.SpyInstance; beforeAll(() => { copySpy = jest.spyOn(copy, 'default'); diff --git a/packages/jaeger-ui-components/src/common/UiFindInput.test.tsx b/packages/jaeger-ui-components/src/common/UiFindInput.test.tsx new file mode 100644 index 00000000000..a2836a147ce --- /dev/null +++ b/packages/jaeger-ui-components/src/common/UiFindInput.test.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import UiFindInput from './UiFindInput'; + +describe('UiFindInput', () => { + describe('rendering', () => { + it('renders as expected with no value', () => { + render(); + const uiFindInput = screen.queryByPlaceholderText('Find...'); + expect(uiFindInput).toBeInTheDocument(); + expect(uiFindInput?.getAttribute('value')).toEqual(''); + }); + + it('renders as expected with value', () => { + render(); + const uiFindInput = screen.queryByPlaceholderText('Find...'); + expect(uiFindInput).toBeInTheDocument(); + expect(uiFindInput?.getAttribute('value')).toEqual('value'); + }); + }); +}); diff --git a/packages/jaeger-ui-components/src/model/ddg/PathElem.test.js b/packages/jaeger-ui-components/src/model/ddg/PathElem.test.ts similarity index 96% rename from packages/jaeger-ui-components/src/model/ddg/PathElem.test.js rename to packages/jaeger-ui-components/src/model/ddg/PathElem.test.ts index e7eba0d49e3..2e835d09bc7 100644 --- a/packages/jaeger-ui-components/src/model/ddg/PathElem.test.js +++ b/packages/jaeger-ui-components/src/model/ddg/PathElem.test.ts @@ -14,12 +14,13 @@ import PathElem from './PathElem'; import { simplePath } from './sample-paths.test.resources'; +import { TDdgOperation, TDdgPath, TDdgService } from './types'; describe('PathElem', () => { const getPath = () => { const path = { focalIdx: 2, - }; + } as TDdgPath; const members = simplePath.map( ({ operation, service }, i) => new PathElem({ @@ -28,8 +29,8 @@ describe('PathElem', () => { name: operation, service: { name: service, - }, - }, + } as TDdgService, + } as TDdgOperation, path, }) ); @@ -42,13 +43,13 @@ describe('PathElem', () => { return path; }; const testMemberIdx = 3; - const testOperation = {}; + const testOperation = {} as TDdgOperation; const testPath = { focalIdx: 4, members: ['member0', 'member1', 'member2', 'member3', 'member4', 'member5'], - }; + } as unknown as TDdgPath; const testVisibilityIdx = 105; - let pathElem; + let pathElem: PathElem; beforeEach(() => { pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testMemberIdx }); diff --git a/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap b/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.ts.snap similarity index 87% rename from packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap rename to packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.ts.snap index 95e81b6a8d3..8a6096a927d 100644 --- a/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap +++ b/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.ts.snap @@ -1,36 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PathElem legibility creates consumable JSON 1`] = ` -Object { +{ "memberIdx": 1, - "memberOf": Object { + "memberOf": { "focalIdx": 2, - "members": Array [ - Object { + "members": [ + { "memberIdx": 0, "operation": "firstOperation", "service": "firstService", "visibilityIdx": 4, }, - Object { + { "memberIdx": 1, "operation": "beforeOperation", "service": "beforeService", "visibilityIdx": 2, }, - Object { + { "memberIdx": 2, "operation": "focalOperation", "service": "focalService", "visibilityIdx": 0, }, - Object { + { "memberIdx": 3, "operation": "afterOperation", "service": "afterService", "visibilityIdx": 1, }, - Object { + { "memberIdx": 4, "operation": "lastOperation", "service": "lastService", diff --git a/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js b/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.ts similarity index 93% rename from packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js rename to packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.ts index b9dadfcaf1b..7bcd7a0f040 100644 --- a/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js +++ b/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -export const simplePayloadElemMaker = (label) => ({ +export const simplePayloadElemMaker = (label: string) => ({ operation: `${label}Operation`, service: `${label}Service`, }); @@ -24,9 +24,9 @@ const sameFocalServicePayloadElem = { service: focalPayloadElem.service, }; -const pathLengthener = (path) => { - const prequels = []; - const sequels = []; +const pathLengthener = (path: Array<{ operation: string; service: string }>) => { + const prequels: Array<{ operation: string; service: string }> = []; + const sequels: Array<{ operation: string; service: string }> = []; path.forEach(({ operation, service }) => { if (operation !== focalPayloadElem.operation && service !== focalPayloadElem.service) { prequels.push({ @@ -115,7 +115,3 @@ export const generationPaths = [ ], [generationPayloadElems.target, generationPayloadElems.beforeFocalMid, focalPayloadElem], ]; - -export const wrap = (paths) => ({ - dependencies: paths.map((path) => ({ path, attributes: [] })), -}); diff --git a/packages/jaeger-ui-components/src/model/find-trace-name.test.js b/packages/jaeger-ui-components/src/model/find-trace-name.test.ts similarity index 93% rename from packages/jaeger-ui-components/src/model/find-trace-name.test.js rename to packages/jaeger-ui-components/src/model/find-trace-name.test.ts index 39c9653a528..bec8fefd8c3 100644 --- a/packages/jaeger-ui-components/src/model/find-trace-name.test.js +++ b/packages/jaeger-ui-components/src/model/find-trace-name.test.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { TraceSpan } from '../types/trace'; + import { _getTraceNameImpl as getTraceName } from './trace-viewer'; describe('getTraceName', () => { @@ -223,22 +225,22 @@ describe('getTraceName', () => { const fullTraceName = `${serviceName}: ${operationName}`; it('returns an empty string if given spans with no root among them', () => { - expect(getTraceName(spansWithNoRoots)).toEqual(''); + expect(getTraceName(spansWithNoRoots as TraceSpan[])).toEqual(''); }); it('returns an id of root span with the earliest startTime', () => { - expect(getTraceName(spansWithMultipleRootsDifferentByStartTime)).toEqual(fullTraceName); + expect(getTraceName(spansWithMultipleRootsDifferentByStartTime as TraceSpan[])).toEqual(fullTraceName); }); it('returns an id of root span without any refs', () => { - expect(getTraceName(spansWithMultipleRootsWithOneWithoutRefs)).toEqual(fullTraceName); + expect(getTraceName(spansWithMultipleRootsWithOneWithoutRefs as unknown as TraceSpan[])).toEqual(fullTraceName); }); it('returns an id of root span with remote ref', () => { - expect(getTraceName(spansWithOneRootWithRemoteRef)).toEqual(fullTraceName); + expect(getTraceName(spansWithOneRootWithRemoteRef as TraceSpan[])).toEqual(fullTraceName); }); it('returns an id of root span with no refs', () => { - expect(getTraceName(spansWithOneRootWithNoRefs)).toEqual(fullTraceName); + expect(getTraceName(spansWithOneRootWithNoRefs as unknown as TraceSpan[])).toEqual(fullTraceName); }); }); diff --git a/packages/jaeger-ui-components/src/model/link-patterns.test.js b/packages/jaeger-ui-components/src/model/link-patterns.test.ts similarity index 93% rename from packages/jaeger-ui-components/src/model/link-patterns.test.js rename to packages/jaeger-ui-components/src/model/link-patterns.test.ts index 22f5c13a047..0a6768febd9 100644 --- a/packages/jaeger-ui-components/src/model/link-patterns.test.js +++ b/packages/jaeger-ui-components/src/model/link-patterns.test.ts @@ -12,12 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Trace, TraceLink, TraceSpan } from '../types/trace'; + import { processTemplate, createTestFunction, getParameterInArray, getParameterInAncestor, processLinkPattern, + ProcessedLinkPattern, computeLinks, createGetLinks, computeTraceLink, @@ -64,7 +67,8 @@ describe('processTemplate()', () => { expect(() => processTemplate( { - template: (data) => `a${data.b}c`, + /* eslint-disable @typescript-eslint/no-explicit-any */ + template: (data: { [key: string]: any }) => `a${data.b}c`, }, (a) => a ) @@ -243,21 +247,28 @@ describe('getParameterInAncestor()', () => { }, tags: [{ key: 'a', value: 'a0' }], }, - ]; + ] as TraceSpan[]; + spans[1].references = [ { + spanID: 's1', + traceID: 't2', refType: 'CHILD_OF', span: spans[0], }, ]; spans[2].references = [ { + spanID: 's1', + traceID: 't2', refType: 'CHILD_OF', span: spans[0], }, ]; spans[3].references = [ { + spanID: 's1', + traceID: 't2', refType: 'CHILD_OF', span: spans[2], }, @@ -311,7 +322,7 @@ describe('getParameterInAncestor()', () => { depth: 0, process: {}, }, - ]; + ] as TraceSpan[]; expect(getParameterInAncestor('a', spansWithUndefinedTags[0])).toBeUndefined(); }); }); @@ -328,7 +339,7 @@ describe('computeTraceLink()', () => { url: 'http://example.com/?myKey=#{traceID}&myKey=#{myKey}', text: 'second link (#{myKey})', }, - ].map(processLinkPattern); + ].map(processLinkPattern) as ProcessedLinkPattern[]; const trace = { processes: [], @@ -338,7 +349,7 @@ describe('computeTraceLink()', () => { endTime: 2000, duration: 1000, services: [], - }; + } as unknown as Trace; it('correctly computes links', () => { expect(computeTraceLink(linkPatterns, trace)).toEqual([ @@ -363,14 +374,16 @@ describe('computeLinks()', () => { url: 'http://example.com/?myKey=#{myOtherKey}&myKey=#{myKey}', text: 'second link (#{myOtherKey})', }, - ].map(processLinkPattern); + ].map(processLinkPattern) as ProcessedLinkPattern[]; const spans = [ { depth: 0, process: {}, tags: [{ key: 'myKey', value: 'valueOfMyKey' }] }, { depth: 1, process: {}, logs: [{ fields: [{ key: 'myOtherKey', value: 'valueOfMy+Other+Key' }] }] }, - ]; + ] as unknown as TraceSpan[]; spans[1].references = [ { + spanID: 's1', + traceID: 't2', refType: 'CHILD_OF', span: spans[0], }, @@ -399,12 +412,13 @@ describe('getLinks()', () => { url: 'http://example.com/?mySpecialKey=#{mySpecialKey}', text: 'special key link (#{mySpecialKey})', }, - ].map(processLinkPattern); - const template = jest.spyOn(linkPatterns[0].url, 'template'); + ].map(processLinkPattern) as ProcessedLinkPattern[]; + const template = jest.spyOn(linkPatterns[0]!.url, 'template'); - const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] }; + const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] } as TraceSpan; - let cache; + /* eslint-disable @typescript-eslint/no-explicit-any */ + let cache: WeakMap; beforeEach(() => { cache = new WeakMap(); @@ -419,7 +433,7 @@ describe('getLinks()', () => { }); it('returns the result from the cache', () => { - const result = []; + const result: TraceLink[] = []; cache.set(span.tags[0], result); const getLinks = createGetLinks(linkPatterns, cache); expect(getLinks(span, span.tags, 0)).toBe(result); diff --git a/packages/jaeger-ui-components/src/model/link-patterns.tsx b/packages/jaeger-ui-components/src/model/link-patterns.tsx index d0d47a676a1..9f74ccb532f 100644 --- a/packages/jaeger-ui-components/src/model/link-patterns.tsx +++ b/packages/jaeger-ui-components/src/model/link-patterns.tsx @@ -28,7 +28,7 @@ type ProcessedTemplate = { template: (template: { [key: string]: any }) => string; }; -type ProcessedLinkPattern = { +export type ProcessedLinkPattern = { object: any; type: (link: string) => boolean; key: (link: string) => boolean; @@ -74,7 +74,7 @@ export function processTemplate(template: unknown, encodeFn: (unencoded: any) => }; } -export function createTestFunction(entry: any) { +export function createTestFunction(entry?: any) { if (typeof entry === 'string') { return (arg: unknown) => arg === entry; } @@ -100,7 +100,7 @@ export function createTestFunction(entry: any) { const identity = (a: any): typeof a => a; -export function processLinkPattern(pattern: any): ProcessedLinkPattern | TNil { +export function processLinkPattern(pattern: any): ProcessedLinkPattern | null { try { const url = processTemplate(pattern.url, encodeURIComponent); const text = processTemplate(pattern.text, identity); @@ -120,7 +120,7 @@ export function processLinkPattern(pattern: any): ProcessedLinkPattern | TNil { } } -export function getParameterInArray(name: string, array: TraceKeyValuePair[]) { +export function getParameterInArray(name: string, array?: TraceKeyValuePair[] | TNil) { if (array) { return array.find((entry) => entry.key === name); } @@ -150,10 +150,10 @@ export function computeTraceLink(linkPatterns: ProcessedLinkPattern[], trace: Tr ); linkPatterns - .filter((pattern) => pattern.type('traces')) + ?.filter((pattern) => pattern?.type('traces')) .forEach((pattern) => { const parameterValues: Record = {}; - const allParameters = pattern.parameters.every((parameter) => { + const allParameters = pattern?.parameters.every((parameter) => { const key = parameter as keyof Trace; if (validKeys.includes(key)) { // At this point is safe to access to trace object using parameter variable because diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.test.js b/packages/jaeger-ui-components/src/model/transform-trace-data.test.ts similarity index 91% rename from packages/jaeger-ui-components/src/model/transform-trace-data.test.js rename to packages/jaeger-ui-components/src/model/transform-trace-data.test.ts index 8dfa857fdcf..980b0bfabb9 100644 --- a/packages/jaeger-ui-components/src/model/transform-trace-data.test.js +++ b/packages/jaeger-ui-components/src/model/transform-trace-data.test.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { TraceResponse } from '../types/trace'; + import transformTraceData, { orderTags, deduplicateTags } from './transform-trace-data'; describe('orderTags()', () => { @@ -136,7 +138,7 @@ describe('transformTraceData()', () => { traceID: undefined, processes, spans, - }; + } as unknown as TraceResponse; expect(transformTraceData(traceData)).toEqual(null); }); @@ -146,9 +148,9 @@ describe('transformTraceData()', () => { traceID, processes, spans: [...spans, rootSpanWithMissingRef], - }; + } as unknown as TraceResponse; - expect(transformTraceData(traceData).traceName).toEqual(`${serviceName}: ${rootOperationName}`); + expect(transformTraceData(traceData)!.traceName).toEqual(`${serviceName}: ${rootOperationName}`); }); it('should return trace data with correct traceName based on root span without any refs', () => { @@ -156,8 +158,8 @@ describe('transformTraceData()', () => { traceID, processes, spans: [...spans, rootSpanWithoutRefs], - }; + } as unknown as TraceResponse; - expect(transformTraceData(traceData).traceName).toEqual(`${serviceName}: ${rootOperationName}`); + expect(transformTraceData(traceData)!.traceName).toEqual(`${serviceName}: ${rootOperationName}`); }); }); diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx index f57036cde50..49c10fac0aa 100644 --- a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx +++ b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx @@ -135,10 +135,13 @@ export default function transformTraceData(data: TraceResponse | undefined): Tra // Eslint complains about number type not needed but then TS complains it is implicitly any. // eslint-disable-next-line @typescript-eslint/no-inferrable-types - tree.walk((spanID: string, node: TreeNode, depth: number = 0) => { + tree.walk((spanID: string | number | undefined, node: TreeNode, depth: number = 0) => { if (spanID === '__root__') { return; } + if (typeof spanID !== 'string') { + return; + } const span = spanMap.get(spanID) as TraceSpan; if (!span) { return; diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.test.js b/packages/jaeger-ui-components/src/utils/TreeNode.test.ts similarity index 97% rename from packages/jaeger-ui-components/src/utils/TreeNode.test.js rename to packages/jaeger-ui-components/src/utils/TreeNode.test.ts index 9ed70686c42..5136cdccb2b 100644 --- a/packages/jaeger-ui-components/src/utils/TreeNode.test.js +++ b/packages/jaeger-ui-components/src/utils/TreeNode.test.ts @@ -280,7 +280,7 @@ it('walk() should iterate over every item and compute the right deep on each nod const nodeB0 = new TreeNode('B0'); const nodeB1 = new TreeNode('B1'); const nodeC3 = new TreeNode('C3'); - const depthMap = { A: 0, B0: 1, B1: 1, C0: 2, C1: 2, C2: 2, C3: 2, D: 3 }; + const depthMap: { [key: string]: number } = { A: 0, B0: 1, B1: 1, C0: 2, C1: 2, C2: 2, C3: 2, D: 3 }; nodeA.addChild(nodeB0); nodeA.addChild(nodeB0); nodeA.addChild(nodeB1); @@ -289,5 +289,9 @@ it('walk() should iterate over every item and compute the right deep on each nod nodeB1.addChild('C2'); nodeB1.addChild(nodeC3); nodeC3.addChild('D'); - nodeA.walk((value, node, depth) => expect(depth).toBe(depthMap[value])); + nodeA.walk((value, _, depth) => { + if (typeof value === 'string') { + expect(depth).toBe(depthMap[value]); + } + }); }); diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.js b/packages/jaeger-ui-components/src/utils/TreeNode.ts similarity index 55% rename from packages/jaeger-ui-components/src/utils/TreeNode.js rename to packages/jaeger-ui-components/src/utils/TreeNode.ts index 3235b0a9972..8f8edeeea1e 100644 --- a/packages/jaeger-ui-components/src/utils/TreeNode.js +++ b/packages/jaeger-ui-components/src/utils/TreeNode.ts @@ -12,26 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. +type SearchFn = (value: string | number | undefined, node: TreeNode, depth?: number) => boolean; + export default class TreeNode { - static iterFunction(fn, depth = 0) { - return (node) => fn(node.value, node, depth); + value: string | number | undefined; + children: TreeNode[]; + + static iterFunction(fn: SearchFn, depth = 0) { + return (node: TreeNode) => fn(node.value, node, depth); } - static searchFunction(search) { + static searchFunction(search: TreeNode | number | SearchFn) { if (typeof search === 'function') { return search; } - return (value, node) => (search instanceof TreeNode ? node === search : value === search); + return (value: string | number | undefined, node: TreeNode) => + search instanceof TreeNode ? node === search : value === search; } - constructor(value, children = []) { + constructor(value?: string | number, children: TreeNode[] = []) { this.value = value; this.children = children; } - get depth() { - return this.children.reduce((depth, child) => Math.max(child.depth + 1, depth), 1); + get depth(): number { + return this.children?.reduce((depth: number, child: { depth: number }) => Math.max(child.depth + 1, depth), 1); } get size() { @@ -40,12 +46,12 @@ export default class TreeNode { return i; } - addChild(child) { - this.children.push(child instanceof TreeNode ? child : new TreeNode(child)); + addChild(child: string | number | TreeNode) { + this.children?.push(child instanceof TreeNode ? child : new TreeNode(child)); return this; } - find(search) { + find(search: TreeNode | number | SearchFn): TreeNode | null { const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); if (searchFn(this)) { return this; @@ -59,10 +65,10 @@ export default class TreeNode { return null; } - getPath(search) { + getPath(search: TreeNode) { const searchFn = TreeNode.iterFunction(TreeNode.searchFunction(search)); - const findPath = (currentNode, currentPath) => { + const findPath = (currentNode: TreeNode, currentPath: TreeNode[]): TreeNode[] | null => { // skip if we already found the result const attempt = currentPath.concat([currentNode]); // base case: return the array when there is a match @@ -82,18 +88,21 @@ export default class TreeNode { return findPath(this, []); } - walk(fn, depth = 0) { - const nodeStack = []; + walk(fn: (value: string | number | undefined, node: TreeNode, depth?: number) => void, depth = 0) { + const nodeStack: Array<{ node: TreeNode; depth?: number }> = []; let actualDepth = depth; nodeStack.push({ node: this, depth: actualDepth }); while (nodeStack.length) { - const { node, depth: nodeDepth } = nodeStack.pop(); - fn(node.value, node, nodeDepth); - actualDepth = nodeDepth + 1; - let i = node.children.length - 1; - while (i >= 0) { - nodeStack.push({ node: node.children[i], depth: actualDepth }); - i--; + const popped = nodeStack.pop(); + if (popped) { + const { node, depth: nodeDepth } = popped; + fn(node.value, node, nodeDepth); + actualDepth = (nodeDepth || 0) + 1; + let i = node.children.length - 1; + while (i >= 0) { + nodeStack.push({ node: node.children[i], depth: actualDepth }); + i--; + } } } } diff --git a/packaging/conf/nfpm.yaml b/packaging/conf/nfpm.yaml deleted file mode 100644 index 03d26fa5ff2..00000000000 --- a/packaging/conf/nfpm.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: "grafana" -arch: "${ARCH}" -platform: "linux" -version: "${VERSION}" -section: "default" -priority: "extra" -replaces: -- grafana -provides: -- grafana-server -- grafana-cli -depends: -- adduser -maintainer: "" -description: | - Grafana -vendor: "Grafana" -homepage: "https://grafana.com" -license: "Apache 2" -bindir: "/usr/sbin" -files: - "./bin/grafana-server": "/usr/sbin/grafana-server" - "./bin/grafana-cli": "/usr/sbin/grafana-cli" -config_files: - ./packaging/deb/init.d/grafana-server: "/etc/init.d/grafana-server" - ./packaging/deb/default/grafana-server: "/etc/default/grafana-server" - ./packaging/deb/systemd/grafana-server.service: "/usr/lib/systemd/system/grafana-server.service" -overrides: - rpm: - scripts: - preinstall: ./scripts/preinstall.sh - postremove: ./scripts/postremove.sh - deb: - scripts: - postinstall: ./packaging/deb/control/postinst diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 952e71e2239..bb7a040a5fd 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -7,23 +7,24 @@ set -e IS_UPGRADE=false -case "$1" in - configure) - [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" - [ -z "$GRAFANA_GROUP" ] && GRAFANA_GROUP="grafana" - if ! getent group "$GRAFANA_GROUP" > /dev/null 2>&1 ; then - addgroup --system "$GRAFANA_GROUP" --quiet - fi - if ! id $GRAFANA_USER > /dev/null 2>&1 ; then - adduser --system --home /usr/share/grafana --no-create-home \ - --ingroup "$GRAFANA_GROUP" --disabled-password --shell /bin/false \ - "$GRAFANA_USER" - fi +# Initial installation: $1 == configure +# Upgrade: $1 == configure, and $2 not empty +if [ "$1" = configure ]; then + [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" + [ -z "$GRAFANA_GROUP" ] && GRAFANA_GROUP="grafana" + if ! getent group "$GRAFANA_GROUP" > /dev/null 2>&1 ; then + addgroup --system "$GRAFANA_GROUP" --quiet + fi + if ! id "$GRAFANA_USER" > /dev/null 2>&1 ; then + adduser --system --home /usr/share/grafana --no-create-home \ + --ingroup "$GRAFANA_GROUP" --disabled-password --shell /bin/false \ + "$GRAFANA_USER" + fi - # Set user permissions on /var/log/grafana, /var/lib/grafana - mkdir -p /var/log/grafana /var/lib/grafana - chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana - chmod 755 /var/log/grafana /var/lib/grafana + # Set user permissions on /var/log/grafana, /var/lib/grafana + mkdir -p /var/log/grafana /var/lib/grafana + chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana + chmod 755 /var/log/grafana /var/lib/grafana # copy user config files if [ ! -f $CONF_FILE ]; then @@ -97,6 +98,5 @@ case "$1" in fi echo " OK" - fi - ;; -esac + fi +fi diff --git a/packaging/deb/init.d/grafana-server b/packaging/deb/init.d/grafana-server index 5c1d9c8271a..840de5e9d62 100755 --- a/packaging/deb/init.d/grafana-server +++ b/packaging/deb/init.d/grafana-server @@ -36,7 +36,7 @@ CONF_FILE=$CONF_DIR/grafana.ini PROVISIONING_CFG_DIR=$CONF_DIR/provisioning MAX_OPEN_FILES=10000 PID_FILE=/var/run/$NAME.pid -DAEMON=/usr/sbin/$NAME +DAEMON=$GRAFANA_HOME/bin/grafana umask 0027 @@ -48,104 +48,102 @@ fi . /lib/lsb/init-functions if [ -r /etc/default/rcS ]; then - . /etc/default/rcS + . /etc/default/rcS fi # overwrite settings from default file if [ -f "$DEFAULT" ]; then - . "$DEFAULT" + . "$DEFAULT" fi -DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=deb cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" +DAEMON_OPTS="server --pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=deb cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" function checkUser() { if [ `id -u` -ne 0 ]; then - echo "You need root privileges to run this script" - exit 4 + echo "You need root privileges to run this script" + exit 4 fi } case "$1" in start) - checkUser - log_daemon_msg "Starting $DESC" + checkUser + log_daemon_msg "Starting $DESC" + pid=`pidofproc -p $PID_FILE grafana` + if [ -n "$pid" ] ; then + log_begin_msg "Already running." + log_end_msg 0 + exit 0 + fi - pid=`pidofproc -p $PID_FILE grafana` - if [ -n "$pid" ] ; then - log_begin_msg "Already running." - log_end_msg 0 - exit 0 - fi + # Prepare environment + mkdir -p "$LOG_DIR" "$DATA_DIR" && chown "$GRAFANA_USER":"$GRAFANA_GROUP" "$LOG_DIR" "$DATA_DIR" + touch "$PID_FILE" && chown "$GRAFANA_USER":"$GRAFANA_GROUP" "$PID_FILE" - # Prepare environment - mkdir -p "$LOG_DIR" "$DATA_DIR" && chown "$GRAFANA_USER":"$GRAFANA_GROUP" "$LOG_DIR" "$DATA_DIR" - touch "$PID_FILE" && chown "$GRAFANA_USER":"$GRAFANA_GROUP" "$PID_FILE" + if [ -n "$MAX_OPEN_FILES" ]; then + ulimit -n $MAX_OPEN_FILES + fi - if [ -n "$MAX_OPEN_FILES" ]; then - ulimit -n $MAX_OPEN_FILES - fi + # Start Daemon + start-stop-daemon --start -b --chdir "$WORK_DIR" --user "$GRAFANA_USER" -c "$GRAFANA_USER" --pidfile "$PID_FILE" --exec $DAEMON -- $DAEMON_OPTS + return=$? + if [ $return -eq 0 ] + then + sleep 1 + # check if pid file has been written to + if ! [[ -s $PID_FILE ]]; then + log_end_msg 1 + exit 1 + fi + i=0 + timeout=10 + # Wait for the process to be properly started before exiting + until { cat "$PID_FILE" | xargs kill -0; } >/dev/null 2>&1 + do + sleep 1 + i=$(($i + 1)) + if [ $i -gt $timeout ]; then + log_end_msg 1 + exit 1 + fi + done + fi - # Start Daemon - start-stop-daemon --start -b --chdir "$WORK_DIR" --user "$GRAFANA_USER" -c "$GRAFANA_USER" --pidfile "$PID_FILE" --exec $DAEMON -- $DAEMON_OPTS - return=$? - if [ $return -eq 0 ] - then - sleep 1 - - # check if pid file has been written to - if ! [[ -s $PID_FILE ]]; then - log_end_msg 1 - exit 1 - fi - - i=0 - timeout=10 - # Wait for the process to be properly started before exiting - until { cat "$PID_FILE" | xargs kill -0; } >/dev/null 2>&1 - do - sleep 1 - i=$(($i + 1)) - if [ $i -gt $timeout ]; then - log_end_msg 1 - exit 1 - fi - done - fi - log_end_msg $return - ;; + log_end_msg $return + ;; stop) - checkUser - log_daemon_msg "Stopping $DESC" + checkUser + log_daemon_msg "Stopping $DESC" - if [ -f "$PID_FILE" ]; then - start-stop-daemon --stop --pidfile "$PID_FILE" \ - --user "$GRAFANA_USER" \ - --retry=TERM/20/KILL/5 >/dev/null - if [ $? -eq 1 ]; then - log_progress_msg "$DESC is not running but pid file exists, cleaning up" - elif [ $? -eq 3 ]; then - PID="`cat $PID_FILE`" - log_failure_msg "Failed to stop $DESC (pid $PID)" - exit 1 - fi - rm -f "$PID_FILE" - else - log_progress_msg "(not running)" - fi - log_end_msg 0 - ;; + if [ -f "$PID_FILE" ]; then + start-stop-daemon --stop --pidfile "$PID_FILE" \ + --user "$GRAFANA_USER" \ + --retry=TERM/20/KILL/5 >/dev/null + if [ $? -eq 1 ]; then + log_progress_msg "$DESC is not running but pid file exists, cleaning up" + elif [ $? -eq 3 ]; then + PID="`cat $PID_FILE`" + log_failure_msg "Failed to stop $DESC (pid $PID)" + exit 1 + fi + rm -f "$PID_FILE" + else + log_progress_msg "(not running)" + fi + log_end_msg 0 + ;; status) - status_of_proc -p $PID_FILE grafana grafana && exit 0 || exit $? + status_of_proc -p $PID_FILE grafana grafana && exit 0 || exit $? ;; restart|force-reload) - if [ -f "$PID_FILE" ]; then - $0 stop - sleep 1 - fi - $0 start - ;; + if [ -f "$PID_FILE" ]; then + $0 stop + sleep 1 + fi + $0 start + ;; *) - log_success_msg "Usage: $0 {start|stop|restart|force-reload|status}" - exit 3 - ;; + log_success_msg "Usage: $0 {start|stop|restart|force-reload|status}" + exit 3 + ;; esac diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index 54be80c06bb..2d66ce889e3 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -14,15 +14,14 @@ Restart=on-failure WorkingDirectory=/usr/share/grafana RuntimeDirectory=grafana RuntimeDirectoryMode=0750 -ExecStart=/usr/sbin/grafana-server \ +ExecStart=/usr/share/grafana/bin/grafana server \ --config=${CONF_FILE} \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \ --packaging=deb \ cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \ - cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR} - + cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR} LimitNOFILE=10000 TimeoutStopSec=20 diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh index 30a70759616..ac1f9ea45a6 100755 --- a/packaging/docker/run.sh +++ b/packaging/docker/run.sh @@ -70,14 +70,14 @@ if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then if [[ $plugin =~ .*\;.* ]]; then pluginUrl=$(echo "$plugin" | cut -d';' -f 1) pluginInstallFolder=$(echo "$plugin" | cut -d';' -f 2) - grafana-cli --pluginUrl ${pluginUrl} --pluginsDir "${GF_PATHS_PLUGINS}" plugins install "${pluginInstallFolder}" + grafana cli --pluginUrl ${pluginUrl} --pluginsDir "${GF_PATHS_PLUGINS}" plugins install "${pluginInstallFolder}" else - grafana-cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin} + grafana cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin} fi done fi -exec grafana-server \ +exec grafana server \ --homepath="$GF_PATHS_HOME" \ --config="$GF_PATHS_CONFIG" \ --packaging=docker \ diff --git a/packaging/mac/bin/grafana b/packaging/mac/bin/grafana index 74f4b00662b..a995c37d9cc 100755 --- a/packaging/mac/bin/grafana +++ b/packaging/mac/bin/grafana @@ -1,6 +1,6 @@ #!/usr/bin/env bash DAEMON=grafana-server -EXECUTABLE=/usr/local/bin/grafana-server +EXECUTABLE=/usr/share/grafana/bin/grafana CONFIG=/usr/local/etc/grafana/grafana.ini HOMEPATH=/usr/local/share/grafana LOGPATH=/usr/local/var/log/grafana @@ -11,7 +11,7 @@ DASHBOARDSCFGPATH=/usr/local/etc/grafana/dashboards case "$1" in start) - $EXECUTABLE --config=$CONFIG --homepath=$HOMEPATH cfg:default.paths.datasources=$DATASOURCECFGPATH cfg:default.paths.dashboards=$DASHBOARDSCFGPATH cfg:default.paths.logs=$LOGPATH cfg:default.paths.data=$DATAPATH cfg:default.paths.plugins=$PLUGINPATH 2> /dev/null & + $EXECUTABLE server --config=$CONFIG --homepath=$HOMEPATH cfg:default.paths.datasources=$DATASOURCECFGPATH cfg:default.paths.dashboards=$DASHBOARDSCFGPATH cfg:default.paths.logs=$LOGPATH cfg:default.paths.data=$DATAPATH cfg:default.paths.plugins=$PLUGINPATH 2> /dev/null & [ $? -eq 0 ] && echo "$DAEMON started" ;; stop) diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index bde2accb9bd..4ec126c3355 100755 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -7,37 +7,42 @@ set -e startGrafana() { if [ -x /bin/systemctl ] ; then /bin/systemctl daemon-reload - /bin/systemctl start grafana-server.service - elif [ -x /etc/init.d/grafana-server ] ; then - /etc/init.d/grafana-server start - elif [ -x /etc/rc.d/init.d/grafana-server ] ; then - /etc/rc.d/init.d/grafana-server start - fi + /bin/systemctl start grafana-server.service + elif [ -x /etc/init.d/grafana-server ] ; then + /etc/init.d/grafana-server start + elif [ -x /etc/rc.d/init.d/grafana-server ] ; then + /etc/rc.d/init.d/grafana-server start + fi } stopGrafana() { - if [ -x /bin/systemctl ] ; then - /bin/systemctl stop grafana-server.service > /dev/null 2>&1 || : - elif [ -x /etc/init.d/grafana-service ] ; then - /etc/init.d/grafana-service stop - elif [ -x /etc/rc.d/init.d/grafana-service ] ; then - /etc/rc.d/init.d/grafana-service stop - fi + if [ -x /bin/systemctl ] ; then + /bin/systemctl stop grafana-server.service > /dev/null 2>&1 || : + elif [ -x /etc/init.d/grafana-service ] ; then + /etc/init.d/grafana-service stop + elif [ -x /etc/rc.d/init.d/grafana-service ] ; then + /etc/rc.d/init.d/grafana-service stop + fi } # Initial installation: $1 == 1 # Upgrade: $1 == 2, and configured to restart on upgrade if [ $1 -eq 1 ] ; then - [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" - [ -z "$GRAFANA_GROUP" ] && GRAFANA_GROUP="grafana" - if ! getent group "$GRAFANA_GROUP" > /dev/null 2>&1 ; then + [ -z "$GRAFANA_USER" ] && GRAFANA_USER="grafana" + [ -z "$GRAFANA_GROUP" ] && GRAFANA_GROUP="grafana" + if ! getent group "$GRAFANA_GROUP" > /dev/null 2>&1 ; then groupadd -r "$GRAFANA_GROUP" - fi - if ! getent passwd "$GRAFANA_USER" > /dev/null 2>&1 ; then - useradd -r -g grafana -d /usr/share/grafana -s /sbin/nologin \ - -c "grafana user" grafana - fi + fi + if ! getent passwd "$GRAFANA_USER" > /dev/null 2>&1 ; then + useradd -r -g "$GRAFANA_GROUP" -d /usr/share/grafana -s /sbin/nologin \ + -c "grafana user" "$GRAFANA_USER" + fi + + # Set user permissions on /var/log/grafana, /var/lib/grafana + mkdir -p /var/log/grafana /var/lib/grafana + chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana + chmod 755 /var/log/grafana /var/lib/grafana # copy user config files if [ ! -f $CONF_FILE ]; then @@ -71,11 +76,6 @@ if [ $1 -eq 1 ] ; then cp /usr/share/grafana/conf/provisioning/alerting/sample.yaml $PROVISIONING_CFG_DIR/alerting/sample.yaml fi - # Set user permissions on /var/log/grafana, /var/lib/grafana - mkdir -p /var/log/grafana /var/lib/grafana - chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana - chmod 755 /var/log/grafana /var/lib/grafana - # configuration files should not be modifiable by grafana user, as this can be a security issue chown -Rh root:$GRAFANA_GROUP /etc/grafana/* chmod 755 /etc/grafana diff --git a/packaging/rpm/init.d/grafana-server b/packaging/rpm/init.d/grafana-server index b7b41e58e8d..5803717eb30 100755 --- a/packaging/rpm/init.d/grafana-server +++ b/packaging/rpm/init.d/grafana-server @@ -22,6 +22,7 @@ PATH=/bin:/usr/bin:/sbin:/usr/sbin NAME=grafana-server DESC="Grafana Server" +DEFAULT=/etc/sysconfig/$NAME GRAFANA_USER=grafana GRAFANA_GROUP=grafana @@ -35,7 +36,7 @@ CONF_FILE=$CONF_DIR/grafana.ini PROVISIONING_CFG_DIR=$CONF_DIR/provisioning MAX_OPEN_FILES=10000 PID_FILE=/var/run/$NAME.pid -DAEMON=/usr/sbin/$NAME +DAEMON=$GRAFANA_HOME/bin/grafana if [ ! -x $DAEMON ]; then echo "Program not installed or not executable" @@ -58,9 +59,11 @@ if [ -f /etc/rc.d/init.d/functions ]; then fi # overwrite settings from default file -[ -e /etc/sysconfig/$NAME ] && . /etc/sysconfig/$NAME +if [ -f "$DEFAULT" ]; then + . "$DEFAULT" +fi -DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=rpm cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" +DAEMON_OPTS="server --pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=rpm cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" function isRunning() { status -p $PID_FILE $NAME > /dev/null 2>&1 diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index e9af40136e4..9752271c6e4 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -14,14 +14,14 @@ Restart=on-failure WorkingDirectory=/usr/share/grafana RuntimeDirectory=grafana RuntimeDirectoryMode=0750 -ExecStart=/usr/sbin/grafana-server \ +ExecStart=/usr/share/grafana/bin/grafana server \ --config=${CONF_FILE} \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \ --packaging=rpm \ cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \ - cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR} + cfg:default.paths.provisioning=${PROVISIONING_CFG_DIR} LimitNOFILE=10000 TimeoutStopSec=20 diff --git a/packaging/wrappers/grafana b/packaging/wrappers/grafana new file mode 100755 index 00000000000..86e0fc9faa2 --- /dev/null +++ b/packaging/wrappers/grafana @@ -0,0 +1,49 @@ +#! /usr/bin/env bash + +# Wrapper for the grafana binary +# This file serves as a wrapper for the grafana binary. It ensures we set +# the system-wide Grafana configuration that was bundled with the package as we +# use the binary. + +DEFAULT=/etc/default/grafana + +GRAFANA_HOME="${GRAFANA_HOME:-/usr/share/grafana}" + +CONF_DIR=/etc/grafana +DATA_DIR=/var/lib/grafana +PLUGINS_DIR=/var/lib/grafana/plugins +LOG_DIR=/var/log/grafana + +CONF_FILE=$CONF_DIR/grafana.ini +PROVISIONING_CFG_DIR=$CONF_DIR/provisioning + +EXECUTABLE="$GRAFANA_HOME/bin/grafana" + +if [ ! -x $EXECUTABLE ]; then + echo "$EXECUTABLE not installed or not executable" + exit 5 +fi + +# overwrite settings from default file +if [ -f "$DEFAULT" ]; then + . "$DEFAULT" +fi + +OPTS="--homepath=${GRAFANA_HOME} \ + --config=${CONF_FILE} \ + --configOverrides='cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR \ + cfg:default.paths.data=${DATA_DIR} \ + cfg:default.paths.logs=${LOG_DIR} \ + cfg:default.paths.plugins=${PLUGINS_DIR}'" + +CMD="${1:-}" +shift + +# special handling to pass --pluginsDir to cli +# can remove once it fully supports cfg:default.paths.plugins +if [ "$CMD" = cli ]; then + OPTS="$OPTS \ + --pluginsDir=${PLUGINS_DIR}" +fi + +eval $EXECUTABLE "$CMD" "$OPTS" "$@" diff --git a/packaging/wrappers/grafana-cli b/packaging/wrappers/grafana-cli index dafa075a2cf..7c6c46aef9e 100755 --- a/packaging/wrappers/grafana-cli +++ b/packaging/wrappers/grafana-cli @@ -1,13 +1,14 @@ #! /usr/bin/env bash -# Wrapper for the grafana-cli binary -# This file serves as a wrapper for the grafana-cli binary. It ensures we set +# Wrapper for the grafana binary +# This file serves as a wrapper for the grafana binary. It ensures we set # the system-wide Grafana configuration that was bundled with the package as we # use the binary. DEFAULT=/etc/default/grafana -GRAFANA_HOME=/usr/share/grafana +GRAFANA_HOME="${GRAFANA_HOME:-/usr/share/grafana}" + CONF_DIR=/etc/grafana DATA_DIR=/var/lib/grafana PLUGINS_DIR=/var/lib/grafana/plugins @@ -16,10 +17,10 @@ LOG_DIR=/var/log/grafana CONF_FILE=$CONF_DIR/grafana.ini PROVISIONING_CFG_DIR=$CONF_DIR/provisioning -EXECUTABLE=$GRAFANA_HOME/bin/grafana-cli +EXECUTABLE="$GRAFANA_HOME/bin/grafana" if [ ! -x $EXECUTABLE ]; then - echo "Program not installed or not executable" + echo "$EXECUTABLE not installed or not executable" exit 5 fi @@ -36,4 +37,6 @@ OPTS="--homepath=${GRAFANA_HOME} \ cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR}'" -eval $EXECUTABLE "$OPTS" '$@' +CMD=cli + +eval $EXECUTABLE "$CMD" "$OPTS" "$@" diff --git a/packaging/wrappers/grafana-server b/packaging/wrappers/grafana-server new file mode 100755 index 00000000000..d8a4fa930fc --- /dev/null +++ b/packaging/wrappers/grafana-server @@ -0,0 +1,41 @@ +#! /usr/bin/env bash + +# Wrapper for the grafana binary +# This file serves as a wrapper for the grafana binary. It ensures we set +# the system-wide Grafana configuration that was bundled with the package as we +# use the binary. + +DEFAULT=/etc/default/grafana + +GRAFANA_HOME="${GRAFANA_HOME:-/usr/share/grafana}" + +CONF_DIR=/etc/grafana +DATA_DIR=/var/lib/grafana +PLUGINS_DIR=/var/lib/grafana/plugins +LOG_DIR=/var/log/grafana + +CONF_FILE=$CONF_DIR/grafana.ini +PROVISIONING_CFG_DIR=$CONF_DIR/provisioning + +EXECUTABLE="$GRAFANA_HOME/bin/grafana" + +if [ ! -x $EXECUTABLE ]; then + echo "$EXECUTABLE not installed or not executable" + exit 5 +fi + +# overwrite settings from default file +if [ -f "$DEFAULT" ]; then + . "$DEFAULT" +fi + +OPTS="--homepath=${GRAFANA_HOME} \ + --config=${CONF_FILE} \ + --configOverrides='cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR \ + cfg:default.paths.data=${DATA_DIR} \ + cfg:default.paths.logs=${LOG_DIR} \ + cfg:default.paths.plugins=${PLUGINS_DIR}'" + +CMD=server + +eval $EXECUTABLE "$CMD" "$OPTS" "$@" diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index e0b244bfab4..54855e7d281 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -238,13 +239,13 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { return nil }) g.Go(func() error { - if err := hs.userAuthService.Delete(ctx, cmd.UserID); err != nil { + if err := hs.authInfoService.DeleteUserAuthInfo(ctx, cmd.UserID); err != nil { return err } return nil }) g.Go(func() error { - if err := hs.userAuthService.DeleteToken(ctx, cmd.UserID); err != nil { + if err := hs.AuthTokenService.RevokeAllUserTokens(ctx, cmd.UserID); err != nil { return err } return nil @@ -416,7 +417,7 @@ func (hs *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) response.Resp // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext) response.Response { - cmd := models.RevokeAuthTokenCmd{} + cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } @@ -476,7 +477,7 @@ type AdminLogoutUserParams struct { type AdminRevokeUserAuthTokenParams struct { // in:body // required:true - Body models.RevokeAuthTokenCmd `json:"body"` + Body auth.RevokeAuthTokenCmd `json:"body"` // in:path // required:true UserID int64 `json:"user_id"` @@ -508,5 +509,5 @@ type AdminCreateUserResponseResponse struct { // swagger:response adminGetUserAuthTokensResponse type AdminGetUserAuthTokensResponse struct { // in:body - Body []*models.UserToken `json:"body"` + Body []*auth.UserToken `json:"body"` } diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 41f79831f74..3de90077df9 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" @@ -65,7 +66,7 @@ func TestAdminAPIEndpoint(t *testing.T) { }) t.Run("When a server admin attempts to revoke an auth token for a non-existing user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} mockUser := usertest.NewUserServiceFake() mockUser.ExpectedError = user.ErrUserNotFound adminRevokeUserAuthTokenScenario(t, "Should return not found when calling POST on", @@ -263,7 +264,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, userService *usertest.FakeUserService) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := HTTPServer{ - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), userService: userService, } @@ -285,9 +286,9 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern }) } -func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, userService user.Service) { +func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd auth.RevokeAuthTokenCmd, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -315,7 +316,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, userService *usertest.FakeUserService) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -341,7 +342,7 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route func adminDisableUserScenario(t *testing.T, desc string, action string, url string, routePattern string, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() authInfoService := &logintest.AuthInfoServiceFake{} diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 20c0fc6dcf6..6abdceba3f2 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -385,7 +385,7 @@ func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePatte func TestAPI_Annotations_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInEditor(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) dashboardAnnotation := &annotations.Item{Id: 1, DashboardId: 1} @@ -787,7 +787,7 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) { func TestAPI_MassDeleteAnnotations_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInEditor(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) type args struct { diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index d6b8e84fc77..38a0f3a534c 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -40,5 +40,5 @@ func ToFolderErrorResponse(err error) response.Response { return response.JSON(412, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()}) } - return response.Error(500, "Folder API error", err) + return response.ErrOrFallback(500, "Folder API error", err) } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index e6f0c159401..20650b3ff93 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -28,7 +28,7 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" @@ -181,7 +181,7 @@ type scenarioContext struct { defaultHandler web.Handler req *http.Request url string - userAuthTokenService *auth.FakeUserAuthTokenService + userAuthTokenService *authtest.FakeUserAuthTokenService sqlStore sqlstore.Store authInfoService *logintest.AuthInfoServiceFake dashboardVersionService dashver.Service @@ -207,14 +207,14 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa cfg.RemoteCacheOptions = &setting.RemoteCacheOptions{ Name: "database", } - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore) loginService := &logintest.LoginServiceFake{} authenticator := &logintest.AuthenticatorFake{} - ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures(), nil) + ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures()) return ctxHdlr } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index ffb0f0098bc..84fc103a2d2 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -396,8 +396,9 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa cmd.UserId = c.UserID if cmd.FolderUid != "" { folder, err := hs.folderService.Get(ctx, &folder.GetFolderQuery{ - OrgID: c.OrgID, - UID: &cmd.FolderUid, + OrgID: c.OrgID, + UID: &cmd.FolderUid, + SignedInUser: c.SignedInUser, }) if err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go index 569255951cb..4a5536ac0dd 100644 --- a/pkg/api/dtos/folder.go +++ b/pkg/api/dtos/folder.go @@ -20,10 +20,10 @@ type Folder struct { Created time.Time `json:"created"` UpdatedBy string `json:"updatedBy"` Updated time.Time `json:"updated"` - Version int `json:"version"` + Version int `json:"version,omitempty"` AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` // only used if nested folders are enabled - ParentUID string `json:"parentUid"` + ParentUID string `json:"parentUid,omitempty"` } type FolderSearchHit struct { diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 31d17005756..9fa6eb79667 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -44,6 +44,7 @@ type CurrentUser struct { Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` Locale string `json:"locale"` + Language string `json:"language"` HelpFlags1 user.HelpFlags1 `json:"helpFlags1"` HasEditPermissionInFolders bool `json:"hasEditPermissionInFolders"` Permissions UserPermissionsMap `json:"permissions,omitempty"` @@ -87,7 +88,7 @@ func (mr *MetricRequest) GetUniqueDatasourceTypes() []string { } } - res := make([]string, 0) + res := make([]string, 0, len(dsTypes)) for dsType := range dsTypes { res = append(res, dsType) } diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index 0c5ac67dc6f..4dc3d7cc97c 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -10,7 +10,7 @@ type Prefs struct { HomeDashboardUID string `json:"homeDashboardUID,omitempty"` Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` - Locale string `json:"locale"` + Language string `json:"language"` Navbar pref.NavbarPreference `json:"navbar,omitempty"` QueryHistory pref.QueryHistoryPreference `json:"queryHistory,omitempty"` } @@ -28,7 +28,7 @@ type UpdatePrefsCmd struct { WeekStart string `json:"weekStart"` Navbar *pref.NavbarPreference `json:"navbar,omitempty"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` - Locale string `json:"locale"` + Language string `json:"language"` } // swagger:model @@ -41,7 +41,7 @@ type PatchPrefsCmd struct { // Enum: utc,browser Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,omitempty"` - Locale *string `json:"locale,omitempty"` + Language *string `json:"language,omitempty"` Navbar *pref.NavbarPreference `json:"navbar,omitempty"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 4373bb2d54f..0c7432da976 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -68,7 +68,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { // 500: internalServerError func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] - folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid, SignedInUser: c.SignedInUser}) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -94,7 +94,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } - folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ID: &id, OrgID: c.OrgID}) + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ID: &id, OrgID: c.OrgID, SignedInUser: c.SignedInUser}) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -122,6 +122,7 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "bad request data", err) } cmd.OrgID = c.OrgID + cmd.SignedInUser = c.SignedInUser folder, err := hs.folderService.Create(c.Req.Context(), &cmd) if err != nil { @@ -211,7 +212,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // } uid := web.Params(c.Req)[":uid"] - err = hs.folderService.DeleteFolder(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.OrgID, ForceDeleteRules: c.QueryBool("forceDeleteRules")}) + err = hs.folderService.DeleteFolder(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.OrgID, ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser}) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -250,6 +251,7 @@ func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardG Updated: folder.Updated, Version: folder.Version, AccessControl: hs.getAccessControlMetadata(c, c.OrgID, dashboards.ScopeFoldersPrefix, folder.UID), + ParentUID: folder.ParentUID, } } diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 555d1a67c8e..09ccd441e11 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -28,7 +28,7 @@ import ( // 500: internalServerError func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] - folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid, SignedInUser: c.SignedInUser}) if err != nil { return apierrors.ToFolderErrorResponse(err) @@ -90,7 +90,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res } uid := web.Params(c.Req)[":uid"] - folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid, SignedInUser: c.SignedInUser}) if err != nil { return apierrors.ToFolderErrorResponse(err) } diff --git a/pkg/api/frontendlogging/sentry.go b/pkg/api/frontendlogging/sentry.go index c91e3a47177..032bb874ef1 100644 --- a/pkg/api/frontendlogging/sentry.go +++ b/pkg/api/frontendlogging/sentry.go @@ -59,7 +59,7 @@ func (value *FrontendSentryExceptionValue) FmtStacktrace(store *SourceMapStore) } func (exception *FrontendSentryException) FmtStacktraces(store *SourceMapStore) string { - var stacktraces []string + stacktraces := make([]string, 0, len(exception.Values)) for _, value := range exception.Values { stacktraces = append(stacktraces, value.FmtStacktrace(store)) } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 855a9564243..1db72e6ef2e 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -198,9 +198,10 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "unifiedAlerting": map[string]interface{}{ "minInterval": hs.Cfg.UnifiedAlerting.MinInterval.String(), }, - "oauth": hs.getEnabledOAuthProviders(), - "samlEnabled": hs.samlEnabled(), - "samlName": hs.samlName(), + "oauth": hs.getEnabledOAuthProviders(), + "samlEnabled": hs.samlEnabled(), + "samlName": hs.samlName(), + "tokenExpirationDayLimit": hs.Cfg.SATokenExpirationDayLimit, } if hs.ThumbService != nil { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index ef37a076523..dc1d1446a43 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -58,7 +58,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. grafanaUpdateChecker: &updatechecker.GrafanaService{}, AccessControl: accesscontrolmock.New().WithDisabled(), PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), - SocialService: social.ProvideService(cfg), + SocialService: social.ProvideService(cfg, features), } m := web.New() diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f2cfce24fc1..222b6923990 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -15,12 +15,12 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware/csrf" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/querylibrary" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/store/object/httpobjectstore" - "github.com/grafana/grafana/pkg/services/userauth" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -120,7 +120,7 @@ type HTTPServer struct { navTreeService navtree.Service CacheService *localcache.CacheService DataSourceCache datasources.CacheService - AuthTokenService models.UserTokenService + AuthTokenService auth.UserTokenService QuotaService quota.Service RemoteCacheService *remotecache.RemoteCache ProvisioningService provisioning.ProvisioningService @@ -206,7 +206,6 @@ type HTTPServer struct { accesscontrolService accesscontrol.Service annotationsRepo annotations.Repository tagService tag.Service - userAuthService userauth.Service oauthTokenService oauthtoken.OAuthTokenService } @@ -220,7 +219,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, pluginClient plugins.Client, pluginErrorResolver plugins.ErrorResolver, pluginInstaller plugins.Installer, settingsProvider setting.Provider, - dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, + dataSourceCache datasources.CacheService, userTokenService auth.UserTokenService, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, correlationsService correlations.Service, thumbService thumbs.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, loginService login.Service, authenticator loginpkg.Authenticator, accessControl accesscontrol.AccessControl, @@ -249,8 +248,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service, accesscontrolService accesscontrol.Service, dashboardThumbsService thumbs.DashboardThumbService, navTreeService navtree.Service, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, - userAuthService userauth.Service, queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, - oauthTokenService oauthtoken.OAuthTokenService, + queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -352,7 +350,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi accesscontrolService: accesscontrolService, annotationsRepo: annotationRepo, tagService: tagService, - userAuthService: userAuthService, QueryLibraryHTTPService: queryLibraryHTTPService, QueryLibraryService: queryLibraryService, oauthTokenService: oauthTokenService, diff --git a/pkg/api/index.go b/pkg/api/index.go index c322b0d489d..dbf621190ef 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -45,15 +45,17 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat return nil, err } - // Set locale to the preference, otherwise fall back to the accept language header. - // In practice, because the preference has configuration-backed default, the header - // shouldn't frequently be used + // Locale is used for some number and date/time formatting, whereas language is used just for + // translating words in the interface acceptLangHeader := c.Req.Header.Get("Accept-Language") locale := "en-US" + language := "" // frontend will set the default language - if hs.Features.IsEnabled(featuremgmt.FlagInternationalization) && prefs.JSONData.Locale != "" { - locale = prefs.JSONData.Locale - } else if len(acceptLangHeader) > 0 { + if hs.Features.IsEnabled(featuremgmt.FlagInternationalization) && prefs.JSONData.Language != "" { + language = prefs.JSONData.Language + } + + if len(acceptLangHeader) > 0 { parts := strings.Split(acceptLangHeader, ",") locale = parts[0] } @@ -77,6 +79,11 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat settings["isPublicDashboardView"] = true } + weekStart := "" + if prefs.WeekStart != nil { + weekStart = *prefs.WeekStart + } + data := dtos.IndexViewData{ User: &dtos.CurrentUser{ Id: c.UserID, @@ -93,8 +100,9 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat IsGrafanaAdmin: c.IsGrafanaAdmin, LightTheme: prefs.Theme == lightName, Timezone: prefs.Timezone, - WeekStart: prefs.WeekStart, + WeekStart: weekStart, Locale: locale, + Language: language, HelpFlags1: c.HelpFlags1, HasEditPermissionInFolders: hasEditPerm, }, diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 5a93f53e3ba..95b92e4e06f 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" @@ -379,7 +379,7 @@ func postSyncUserWithLDAPContext(t *testing.T, requestURL string, preHook func(* hs := &HTTPServer{ Cfg: sc.cfg, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), Login: loginservice.LoginServiceMock{}, authInfoService: sc.authInfoService, userService: userService, diff --git a/pkg/api/login.go b/pkg/api/login.go index 20baa31bde5..9b23ad05b6e 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" loginService "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/user" @@ -227,7 +228,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { err = hs.loginUserWithUser(usr, c) if err != nil { - var createTokenErr *models.CreateTokenErr + var createTokenErr *auth.CreateTokenErr if errors.As(err, &createTokenErr) { resp = response.Error(createTokenErr.StatusCode, createTokenErr.ExternalErr, createTokenErr.InternalErr) } else { @@ -299,7 +300,7 @@ func (hs *HTTPServer) Logout(c *models.ReqContext) { } err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken, false) - if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { + if err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { hs.log.Error("failed to revoke auth token", "error", err) } @@ -370,7 +371,7 @@ func (hs *HTTPServer) samlSingleLogoutEnabled() bool { } func getLoginExternalError(err error) string { - var createTokenErr *models.CreateTokenErr + var createTokenErr *auth.CreateTokenErr if errors.As(err, &createTokenErr) { return createTokenErr.ExternalErr } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 88a465d7fdb..603f158611a 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -97,9 +97,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { code := ctx.Query("code") if code == "" { - // FIXME: access_type is a Google OAuth2 specific thing, consider refactoring this and moving to google_oauth.go - opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} - + var opts []oauth2.AuthCodeOption if provider.UsePKCE { ascii, pkce, err := genPKCECode() if err != nil { diff --git a/pkg/api/login_oauth_test.go b/pkg/api/login_oauth_test.go index b5143949443..f39c5cda96c 100644 --- a/pkg/api/login_oauth_test.go +++ b/pkg/api/login_oauth_test.go @@ -9,15 +9,15 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/secrets/fakes" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -36,7 +36,7 @@ func setupOAuthTest(t *testing.T, cfg *setting.Cfg) *web.Mux { Cfg: cfg, License: &licensing.OSSLicensingService{Cfg: cfg}, SQLStore: sqlStore, - SocialService: social.ProvideService(cfg), + SocialService: social.ProvideService(cfg, featuremgmt.WithFeatures()), HooksService: hooks.ProvideService(), SecretsService: fakes.NewFakeSecretsService(), } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 27452b9bd09..ea30addb451 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -12,8 +12,6 @@ import ( "strings" "testing" - loginservice "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/navtree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,9 +23,11 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" + loginservice "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -323,7 +323,7 @@ func TestLoginPostRedirect(t *testing.T) { Cfg: setting.NewCfg(), HooksService: &hooks.HooksService{}, License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), } hs.Cfg.CookieSecure = true @@ -564,7 +564,7 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte Cfg: sc.cfg, SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), log: log.New("hello"), SocialService: &mockSocialService{}, } @@ -602,7 +602,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { log: log.New("test"), Cfg: setting.NewCfg(), License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), HooksService: hookService, } diff --git a/pkg/api/org_invite_test.go b/pkg/api/org_invite_test.go index ea0960bb5a2..e264b76a865 100644 --- a/pkg/api/org_invite_test.go +++ b/pkg/api/org_invite_test.go @@ -72,7 +72,7 @@ func TestOrgInvitesAPIEndpointAccess(t *testing.T) { userService.ExpectedUser = &user.User{ID: 2} sc.hs.userService = userService setInitCtxSignedInViewer(sc.initCtx) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setAccessControlPermissions(sc.acmock, test.permissions, sc.initCtx.OrgID) input := strings.NewReader(test.input) diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index 49ed8000aae..4dcd2fe2e3e 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "net/http" "strings" @@ -9,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -49,7 +51,7 @@ func TestAPIEndpoint_GetCurrentOrg_LegacyAccessControl(t *testing.T) { sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) t.Run("Viewer can view CurrentOrg", func(t *testing.T) { @@ -68,7 +70,7 @@ func TestAPIEndpoint_GetCurrentOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) t.Run("AccessControl allows viewing CurrentOrg with correct permissions", func(t *testing.T) { @@ -93,7 +95,7 @@ func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgNameForm) @@ -117,7 +119,7 @@ func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", sc.initCtx.UserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: sc.initCtx.UserID}) require.NoError(t, err) sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) @@ -148,7 +150,7 @@ func TestAPIEndpoint_PutCurrentOrgAddress_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgAddressForm) @@ -170,7 +172,7 @@ func TestAPIEndpoint_PutCurrentOrgAddress_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgAddressForm) @@ -206,7 +208,7 @@ func setupOrgsDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore, c acc // Create `orgsCount` orgs for i := 1; i <= int(orgID); i++ { - _, err := db.CreateOrgWithMember(fmt.Sprintf("TestOrg%v", i), 0) + err := db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: fmt.Sprintf("TestOrg%v", i), UserId: 0}) require.NoError(t, err) } } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 3dbe71300ae..caf88809c64 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -328,7 +328,7 @@ var ( // setupOrgUsersDBForAccessControlTests creates three users placed in two orgs // Org1: testServerAdminViewer, testEditorOrg1 // Org2: testServerAdminViewer, testAdminOrg2 -func setupOrgUsersDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore) { +func setupOrgUsersDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore, orgService org.Service) { t.Helper() var err error @@ -341,14 +341,14 @@ func setupOrgUsersDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore) { require.NoError(t, err) // Create both orgs with server admin - _, err = db.CreateOrgWithMember(testServerAdminViewer.OrgName, testServerAdminViewer.UserID) + err = db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: testServerAdminViewer.OrgName, UserId: testServerAdminViewer.UserID}) require.NoError(t, err) - _, err = db.CreateOrgWithMember(testAdminOrg2.OrgName, testServerAdminViewer.UserID) + err = db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: testAdminOrg2.OrgName, UserId: testServerAdminViewer.UserID}) require.NoError(t, err) - err = db.AddOrgUser(context.Background(), &models.AddOrgUserCommand{LoginOrEmail: testAdminOrg2.Login, Role: testAdminOrg2.OrgRole, OrgId: testAdminOrg2.OrgID, UserId: testAdminOrg2.UserID}) + err = orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{LoginOrEmail: testAdminOrg2.Login, Role: testAdminOrg2.OrgRole, OrgID: testAdminOrg2.OrgID, UserID: testAdminOrg2.UserID}) require.NoError(t, err) - err = db.AddOrgUser(context.Background(), &models.AddOrgUserCommand{LoginOrEmail: testEditorOrg1.Login, Role: testEditorOrg1.OrgRole, OrgId: testEditorOrg1.OrgID, UserId: testEditorOrg1.UserID}) + err = orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{LoginOrEmail: testEditorOrg1.Login, Role: testEditorOrg1.OrgRole, OrgID: testEditorOrg1.OrgID, UserID: testEditorOrg1.UserID}) require.NoError(t, err) } @@ -398,7 +398,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotatest.New(false, nil)) require.NoError(t, err) }) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setInitCtxSignedInUser(sc.initCtx, tc.user) // Perform test @@ -506,7 +506,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControl(t *testing.T) { require.NoError(t, err) }) setInitCtxSignedInUser(sc.initCtx, tc.user) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) // Perform test response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(url, tc.targetOrg), nil, t) @@ -606,12 +606,14 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) { cfg.RBACEnabled = tc.enableAccessControl var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotatest.New(false, nil)) + require.NoError(t, err) hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) + hs.SQLStore, hs.orgService, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) require.NoError(t, err) }) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setInitCtxSignedInUser(sc.initCtx, tc.user) // Perform request @@ -731,7 +733,7 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { require.NoError(t, err) }) setInitCtxSignedInViewer(sc.initCtx) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setAccessControlPermissions(sc.acmock, test.permissions, sc.initCtx.OrgID) input := strings.NewReader(test.input) @@ -852,7 +854,7 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) require.NoError(t, err) }) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setInitCtxSignedInUser(sc.initCtx, tc.user) // Perform request @@ -982,7 +984,7 @@ func TestDeleteOrgUsersAPIEndpoint_AccessControl(t *testing.T) { hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) require.NoError(t, err) }) - setupOrgUsersDBForAccessControlTests(t, sc.db) + setupOrgUsersDBForAccessControlTests(t, sc.db, sc.hs.orgService) setInitCtxSignedInUser(sc.initCtx, tc.user) response := callAPI(sc.server, http.MethodDelete, fmt.Sprintf(url, tc.targetOrg, tc.targetUserId), nil, t) diff --git a/pkg/api/password.go b/pkg/api/password.go index fe41c9ca5fa..c21c30f0ae2 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -60,7 +60,11 @@ func (hs *HTTPServer) ResetPassword(c *models.ReqContext) response.Response { } query := models.ValidateResetPasswordCodeQuery{Code: form.Code} + // For now the only way to know the username to clear login attempts for is + // to set it in the function provided to NotificationService + var username string getUserByLogin := func(ctx context.Context, login string) (*user.User, error) { + username = login userQuery := user.GetUserByLoginQuery{LoginOrEmail: login} usr, err := hs.userService.GetByLogin(ctx, &userQuery) return usr, err @@ -94,5 +98,9 @@ func (hs *HTTPServer) ResetPassword(c *models.ReqContext) response.Response { return response.Error(500, "Failed to change user password", err) } + if err := hs.loginAttemptService.Reset(c.Req.Context(), username); err != nil { + c.Logger.Warn("could not reset login attempts", "err", err, "username", username) + } + return response.Success("User password changed") } diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index d505e05331e..e5e2a40a009 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -82,16 +82,21 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team } } + weekStart := "" + if preference.WeekStart != nil { + weekStart = *preference.WeekStart + } + dto := dtos.Prefs{ Theme: preference.Theme, HomeDashboardID: preference.HomeDashboardID, HomeDashboardUID: dashboardUID, Timezone: preference.Timezone, - WeekStart: preference.WeekStart, + WeekStart: weekStart, } if preference.JSONData != nil { - dto.Locale = preference.JSONData.Locale + dto.Language = preference.JSONData.Language dto.Navbar = preference.JSONData.Navbar dto.QueryHistory = preference.JSONData.QueryHistory } @@ -144,7 +149,7 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t OrgID: orgID, TeamID: teamId, Theme: dtoCmd.Theme, - Locale: dtoCmd.Locale, + Language: dtoCmd.Language, Timezone: dtoCmd.Timezone, WeekStart: dtoCmd.WeekStart, HomeDashboardID: dtoCmd.HomeDashboardID, @@ -207,7 +212,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te Timezone: dtoCmd.Timezone, WeekStart: dtoCmd.WeekStart, HomeDashboardID: dtoCmd.HomeDashboardID, - Locale: dtoCmd.Locale, + Language: dtoCmd.Language, Navbar: dtoCmd.Navbar, QueryHistory: dtoCmd.QueryHistory, } diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index 3e11d2a13a6..2edd6d75951 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "io" "net/http" @@ -50,7 +51,7 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_LegacyAccessControl(t *testing.T) prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} sc.hs.preferenceService = prefService - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) setInitCtxSignedInViewer(sc.initCtx) @@ -79,7 +80,7 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_AccessControl(t *testing.T) { prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} sc.hs.preferenceService = prefService - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) t.Run("AccessControl allows getting org preferences with correct permissions", func(t *testing.T) { @@ -104,7 +105,7 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_LegacyAccessControl(t *testing.T) cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) setInitCtxSignedInViewer(sc.initCtx) @@ -126,7 +127,7 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) input := strings.NewReader(testUpdateOrgPreferencesCmd) @@ -156,7 +157,7 @@ func TestAPIEndpoint_PatchUserPreferences(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) setInitCtxSignedInOrgAdmin(sc.initCtx) @@ -189,7 +190,7 @@ func TestAPIEndpoint_PatchOrgPreferences(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) - _, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) + err := sc.db.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "TestOrg", UserId: testUserID}) require.NoError(t, err) setInitCtxSignedInOrgAdmin(sc.initCtx) diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index ea05e46753c..41128c9cf37 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -127,8 +127,10 @@ func setupTeamTestScenario(userCount int, db *sqlstore.SQLStore, t *testing.T) i teamService := teamimpl.ProvideService(db, setting.NewCfg()) // FIXME user, err := db.CreateUser(context.Background(), user.CreateUserCommand{SkipOrgSetup: true, Login: testUserLogin}) require.NoError(t, err) - testOrg, err := db.CreateOrgWithMember("TestOrg", user.ID) + cmd := &models.CreateOrgCommand{Name: "TestOrg", UserId: user.ID} + err = db.CreateOrg(context.Background(), cmd) require.NoError(t, err) + testOrg := cmd.Result team, err := teamService.CreateTeam("test", "test@test.com", testOrg.Id) require.NoError(t, err) diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 772cb0ff183..3e12fca2d2e 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -43,7 +44,7 @@ func (hs *HTTPServer) GetUserAuthTokens(c *models.ReqContext) response.Response // 403: forbiddenError // 500: internalServerError func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext) response.Response { - cmd := models.RevokeAuthTokenCmd{} + cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } @@ -143,7 +144,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int return response.JSON(http.StatusOK, result) } -func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response { +func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd auth.RevokeAuthTokenCmd) response.Response { userQuery := user.GetUserByIDQuery{ID: userID} _, err := hs.userService.GetByID(c.Req.Context(), &userQuery) if err != nil { @@ -155,7 +156,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i token, err := hs.AuthTokenService.GetUserToken(c.Req.Context(), userID, cmd.AuthTokenId) if err != nil { - if errors.Is(err, models.ErrUserTokenNotFound) { + if errors.Is(err, auth.ErrUserTokenNotFound) { return response.Error(404, "User auth token not found", err) } return response.Error(500, "Failed to get user auth token", err) @@ -167,7 +168,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false) if err != nil { - if errors.Is(err, models.ErrUserTokenNotFound) { + if errors.Is(err, auth.ErrUserTokenNotFound) { return response.Error(404, "User auth token not found", err) } return response.Error(500, "Failed to revoke user auth token", err) @@ -182,11 +183,11 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i type RevokeUserAuthTokenParams struct { // in:body // required:true - Body models.RevokeAuthTokenCmd `json:"body"` + Body auth.RevokeAuthTokenCmd `json:"body"` } // swagger:response getUserAuthTokensResponse type GetUserAuthTokensResponse struct { // in:body - Body []*models.UserToken `json:"body"` + Body []*auth.UserToken `json:"body"` } diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index 2dd7d7e7fbd..093a27011b9 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -20,7 +21,7 @@ import ( func TestUserTokenAPIEndpoint(t *testing.T) { userMock := usertest.NewUserServiceFake() t.Run("When current user attempts to revoke an auth token for a non-existing user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} userMock.ExpectedError = user.ErrUserNotFound revokeUserAuthTokenScenario(t, "Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { @@ -59,15 +60,15 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When revoke an auth token for a user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - token := &models.UserToken{Id: 1} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &auth.UserToken{Id: 1} mockUser := &usertest.FakeUserService{ ExpectedUser: &user.User{ID: 200}, } revokeUserAuthTokenInternalScenario(t, "Should be successful", cmd, 200, token, func(sc *scenarioContext) { - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { - return &models.UserToken{Id: 2}, nil + sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { + return &auth.UserToken{Id: 2}, nil } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -75,11 +76,11 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When revoke the active auth token used by himself", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - token := &models.UserToken{Id: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &auth.UserToken{Id: 2} mockUser := usertest.NewUserServiceFake() revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) { - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { + sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return token, nil } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() @@ -88,10 +89,10 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When gets auth tokens for a user", func(t *testing.T) { - currentToken := &models.UserToken{Id: 1} + currentToken := &auth.UserToken{Id: 1} mockUser := usertest.NewUserServiceFake() getUserAuthTokensInternalScenario(t, "Should be successful", currentToken, func(sc *scenarioContext) { - tokens := []*models.UserToken{ + tokens := []*auth.UserToken{ { Id: 1, ClientIp: "127.0.0.1", @@ -107,7 +108,7 @@ func TestUserTokenAPIEndpoint(t *testing.T) { SeenAt: 0, }, } - sc.userAuthTokenService.GetUserTokensProvider = func(ctx context.Context, userId int64) ([]*models.UserToken, error) { + sc.userAuthTokenService.GetUserTokensProvider = func(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return tokens, nil } sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -145,10 +146,10 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) } -func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, +func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd auth.RevokeAuthTokenCmd, userId int64, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -175,7 +176,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -202,7 +203,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { hs := HTTPServer{ - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), userService: userService, } @@ -222,10 +223,10 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId }) } -func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64, - token *models.UserToken, fn scenarioFunc, userService user.Service) { +func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd auth.RevokeAuthTokenCmd, userId int64, + token *auth.UserToken, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -248,9 +249,9 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R }) } -func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, userService user.Service) { +func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *auth.UserToken, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, diff --git a/pkg/build/cmd.go b/pkg/build/cmd.go index bd0128d0cd4..2476caf2d42 100644 --- a/pkg/build/cmd.go +++ b/pkg/build/cmd.go @@ -17,11 +17,12 @@ const ( GoOSWindows = "windows" GoOSLinux = "linux" - ServerBinary = "grafana-server" - CLIBinary = "grafana-cli" + BackendBinary = "grafana" + ServerBinary = "grafana-server" + CLIBinary = "grafana-cli" ) -var binaries = []string{ServerBinary, CLIBinary} +var binaries = []string{BackendBinary, ServerBinary, CLIBinary} func logError(message string, err error) int { log.Println(message, err) @@ -64,6 +65,16 @@ func RunCmd() int { case "setup": setup(opts.goos) + case "build-backend": + if !opts.isDev { + clean(opts) + } + + if err := doBuild("grafana", "./pkg/cmd/grafana", opts); err != nil { + log.Println(err) + return 1 + } + case "build-srv", "build-server": if !opts.isDev { clean(opts) diff --git a/pkg/build/cmd/genversions.go b/pkg/build/cmd/genversions.go index 81879f42b55..0d293499430 100644 --- a/pkg/build/cmd/genversions.go +++ b/pkg/build/cmd/genversions.go @@ -19,6 +19,11 @@ func GenerateMetadata(c *cli.Context) (config.Metadata, error) { return config.Metadata{}, err } + tag, ok := os.LookupEnv("DRONE_TAG") + if !ok { + fmt.Println("DRONE_TAG envvar not present, %w", err) + } + var releaseMode config.ReleaseMode switch event { case string(config.PullRequestMode): @@ -30,6 +35,13 @@ func GenerateMetadata(c *cli.Context) (config.Metadata, error) { } releaseMode = config.ReleaseMode{Mode: mode} case config.Custom: + if edition, _ := os.LookupEnv("EDITION"); edition == string(config.EditionEnterprise2) { + releaseMode = config.ReleaseMode{Mode: config.TagMode} + if tag != "" { + version = strings.TrimPrefix(tag, "v") + } + break + } mode, err := config.CheckDroneTargetBranch() if err != nil { return config.Metadata{}, err @@ -41,9 +53,8 @@ func GenerateMetadata(c *cli.Context) (config.Metadata, error) { releaseMode = config.ReleaseMode{Mode: mode} } case config.Tag, config.Promote: - tag, ok := os.LookupEnv("DRONE_TAG") - if !ok || tag == "" { - return config.Metadata{}, err + if tag == "" { + return config.Metadata{}, fmt.Errorf("DRONE_TAG envvar not present for a tag/promotion event, %w", err) } version = strings.TrimPrefix(tag, "v") mode, err := config.CheckSemverSuffix() diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index 46d8ef8f32a..658cceb9011 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -96,6 +96,14 @@ func main() { }, }, }, + { + Name: "upload-cdn", + Usage: "Upload public/* to a cdn bucket", + Action: UploadCDN, + Flags: []cli.Flag{ + &editionFlag, + }, + }, { Name: "shellcheck", Usage: "Run shellcheck on shell scripts", @@ -153,6 +161,15 @@ func main() { }, }, }, + { + Name: "upload-packages", + Usage: "Upload Grafana packages", + Action: UploadPackages, + Flags: []cli.Flag{ + &jobsFlag, + &editionFlag, + }, + }, { Name: "artifacts", Usage: "Handle Grafana artifacts", @@ -170,6 +187,18 @@ func main() { &editionFlag, }, }, + { + Name: "publish-enterprise2", + Usage: "Handle Grafana Enterprise2 Docker images", + ArgsUsage: "[version]", + Action: Enterprise2, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dockerhub-repo", + Usage: "DockerHub repo to push images", + }, + }, + }, }, }, }, @@ -196,7 +225,7 @@ func main() { { Name: "github", Usage: "Publish packages to GitHub releases", - Action: PublishGitHub, + Action: PublishGithub, Flags: []cli.Flag{ &dryRunFlag, &cli.StringFlag{ @@ -211,7 +240,7 @@ func main() { }, &cli.StringFlag{ Name: "tag", - Usage: "Release tag (default from metadata)ß", + Usage: "Release tag (default from metadata)", }, &cli.BoolFlag{ Name: "create", @@ -219,6 +248,33 @@ func main() { }, }, }, + { + Name: "aws", + Usage: "Publish image to AWS Marketplace releases", + Action: PublishAwsMarketplace, + Flags: []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "version", + Usage: "Release version (default from metadata)", + }, + &cli.StringFlag{ + Name: "image", + Required: true, + Usage: "Name of the image to be released", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "AWS Marketplace ECR repository", + }, + &cli.StringFlag{ + Name: "product", + Required: true, + Usage: "AWS Marketplace product identifier", + }, + }, + }, }, }, } diff --git a/pkg/build/cmd/package.go b/pkg/build/cmd/package.go index 59beff98520..37b8d2e81fd 100644 --- a/pkg/build/cmd/package.go +++ b/pkg/build/cmd/package.go @@ -31,12 +31,9 @@ func Package(c *cli.Context) error { } cfg := config.Config{ - NumWorkers: c.Int("jobs"), + NumWorkers: c.Int("jobs"), + SignPackages: c.Bool("sign"), } - if err := gpg.LoadGPGKeys(&cfg); err != nil { - return cli.Exit(err, 1) - } - defer gpg.RemoveGPGFiles(cfg) ctx := context.Background() @@ -57,8 +54,14 @@ func Package(c *cli.Context) error { log.Printf("Packaging Grafana version %q, version mode %s, %s edition, variants %s", metadata.GrafanaVersion, releaseMode.Mode, edition, strings.Join(variantStrs, ",")) - if err := gpg.Import(cfg); err != nil { - return cli.Exit(err, 1) + if cfg.SignPackages { + if err := gpg.LoadGPGKeys(&cfg); err != nil { + return cli.Exit(err, 1) + } + defer gpg.RemoveGPGFiles(cfg) + if err := gpg.Import(cfg); err != nil { + return cli.Exit(err, 1) + } } p := syncutil.NewWorkerPool(cfg.NumWorkers) diff --git a/pkg/build/cmd/publishaws.go b/pkg/build/cmd/publishaws.go new file mode 100644 index 00000000000..68ed5e695ac --- /dev/null +++ b/pkg/build/cmd/publishaws.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go/service/marketplacecatalog" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/urfave/cli/v2" +) + +const ( + marketplaceChangeSetName = "Add new version" + marketplaceCatalogId = "AWSMarketplace" + marketplaceRegistryId = "709825985650" + marketplaceRegistryRegion = "us-east-1" + marketplaceRegistryUrl = "709825985650.dkr.ecr.us-east-1.amazonaws.com" + marketplaceRequestsUrl = "https://aws.amazon.com/marketplace/management/requests/" + releaseNotesTemplateUrl = "https://grafana.com/docs/grafana/latest/release-notes/release-notes-${TAG}/" + helmChartsUrl = "https://grafana.github.io/helm-charts/" + docsUrl = "https://grafana.com/docs/grafana/latest/enterprise/license/" + imagePlatform = "linux/amd64" + + publishAwsMarketplaceTestKey publishAwsMarketplaceTestKeyType = "test-client" +) + +var ( + errEmptyVersion = errors.New(`failed to retrieve release version from metadata, use "--version" to set it manually`) +) + +type publishAwsMarketplaceTestKeyType string + +type publishAwsMarketplaceFlags struct { + dryRun bool + version string + repo string + image string + product string +} + +type AwsMarketplacePublishingService struct { + auth string + docker AwsMarketplaceDocker + ecr AwsMarketplaceRegistry + mkt AwsMarketplaceCatalog +} + +type AwsMarketplaceDocker interface { + ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) + ImageTag(ctx context.Context, source string, target string) error + ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error) +} + +type AwsMarketplaceRegistry interface { + GetAuthorizationTokenWithContext(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...request.Option) (*ecr.GetAuthorizationTokenOutput, error) +} + +type AwsMarketplaceCatalog interface { + DescribeEntityWithContext(ctx context.Context, input *marketplacecatalog.DescribeEntityInput, opts ...request.Option) (*marketplacecatalog.DescribeEntityOutput, error) + StartChangeSetWithContext(ctx context.Context, input *marketplacecatalog.StartChangeSetInput, opts ...request.Option) (*marketplacecatalog.StartChangeSetOutput, error) +} + +func PublishAwsMarketplace(ctx *cli.Context) error { + f, err := getPublishAwsMarketplaceFlags(ctx) + if err != nil { + return err + } + + if f.version == "" { + return errEmptyVersion + } + + svc, err := getAwsMarketplacePublishingService() + if err != nil { + return err + } + + if ctx.Context.Value(publishAwsMarketplaceTestKey) != nil { + svc = ctx.Context.Value(publishAwsMarketplaceTestKey).(*AwsMarketplacePublishingService) + } + + fmt.Println("Logging in to AWS Marketplace registry") + err = svc.Login(ctx.Context) + if err != nil { + return err + } + + fmt.Printf("Retrieving image '%s:%s' from Docker Hub\n", f.image, f.version) + err = svc.PullImage(ctx.Context, f.image, f.version) + if err != nil { + return err + } + + fmt.Printf("Renaming image '%s:%s' to '%s/%s:%s'\n", f.image, f.version, marketplaceRegistryUrl, f.repo, f.version) + err = svc.TagImage(ctx.Context, f.image, f.repo, f.version) + if err != nil { + return err + } + + if !f.dryRun { + fmt.Printf("Pushing image '%s/%s:%s' to the AWS Marketplace ECR\n", marketplaceRegistryUrl, f.repo, f.version) + err = svc.PushToMarketplace(ctx.Context, f.repo, f.version) + if err != nil { + return err + } + } else { + fmt.Printf("Dry-Run: Pushing image '%s/%s:%s' to the AWS Marketplace ECR\n", marketplaceRegistryUrl, f.repo, f.version) + } + + fmt.Printf("Retrieving product identifier for product '%s'\n", f.product) + pid, err := svc.GetProductIdentifier(ctx.Context, f.product) + if err != nil { + return err + } + + if !f.dryRun { + fmt.Printf("Releasing to product, you can view the progress of the release on %s\n", marketplaceRequestsUrl) + return svc.ReleaseToProduct(ctx.Context, pid, f.repo, f.version) + } else { + fmt.Printf("Dry-Run: Releasing to product, you can view the progress of the release on %s\n", marketplaceRequestsUrl) + } + + return nil +} + +func getAwsMarketplacePublishingService() (*AwsMarketplacePublishingService, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + + mySession := session.Must(session.NewSession()) + ecr := ecr.New(mySession, aws.NewConfig().WithRegion(marketplaceRegistryRegion)) + mkt := marketplacecatalog.New(mySession, aws.NewConfig().WithRegion(marketplaceRegistryRegion)) + return &AwsMarketplacePublishingService{ + docker: cli, + ecr: ecr, + mkt: mkt, + }, nil +} + +func (s *AwsMarketplacePublishingService) Login(ctx context.Context) error { + out, err := s.ecr.GetAuthorizationTokenWithContext(ctx, &ecr.GetAuthorizationTokenInput{}) + if err != nil { + return err + } + s.auth = *out.AuthorizationData[0].AuthorizationToken + authData, err := base64.StdEncoding.DecodeString(s.auth) + if err != nil { + return err + } + authString := strings.Split(string(authData), ":") + authData, err = json.Marshal(types.AuthConfig{ + Username: authString[0], + Password: authString[1], + }) + s.auth = base64.StdEncoding.EncodeToString(authData) + return err +} + +func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, image string, version string) error { + reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", image, version), types.ImagePullOptions{ + Platform: imagePlatform, + }) + if err != nil { + return err + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + return err + } + + err = reader.Close() + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) TagImage(ctx context.Context, image string, repo string, version string) error { + err := s.docker.ImageTag(ctx, fmt.Sprintf("%s:%s", image, version), fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version)) + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) PushToMarketplace(ctx context.Context, repo string, version string) error { + reader, err := s.docker.ImagePush(ctx, fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version), types.ImagePushOptions{ + RegistryAuth: s.auth, + }) + if err != nil { + return err + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + return err + } + + err = reader.Close() + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) GetProductIdentifier(ctx context.Context, product string) (string, error) { + out, err := s.mkt.DescribeEntityWithContext(ctx, &marketplacecatalog.DescribeEntityInput{ + EntityId: aws.String(product), + Catalog: aws.String(marketplaceCatalogId), + }) + if err != nil { + return "", err + } + return *out.EntityIdentifier, nil +} + +func (s *AwsMarketplacePublishingService) ReleaseToProduct(ctx context.Context, pid string, repo string, version string) error { + _, err := s.mkt.StartChangeSetWithContext(ctx, &marketplacecatalog.StartChangeSetInput{ + Catalog: aws.String(marketplaceCatalogId), + ChangeSetName: aws.String(marketplaceChangeSetName), + ChangeSet: []*marketplacecatalog.Change{ + buildAwsMarketplaceChangeSet(pid, repo, version), + }, + }) + return err +} + +func getPublishAwsMarketplaceFlags(ctx *cli.Context) (*publishAwsMarketplaceFlags, error) { + metadata, err := GenerateMetadata(ctx) + if err != nil { + return nil, err + } + version := ctx.String("version") + if version == "" && metadata.GrafanaVersion != "" { + version = metadata.GrafanaVersion + } + image := ctx.String("image") + repo := ctx.String("repo") + product := ctx.String("product") + dryRun := ctx.Bool("dry-run") + return &publishAwsMarketplaceFlags{ + dryRun: dryRun, + version: version, + image: image, + repo: repo, + product: product, + }, nil +} + +func buildAwsMarketplaceReleaseNotesUrl(version string) string { + sanitizedVersion := strings.ReplaceAll(version, ".", "-") + return strings.ReplaceAll(releaseNotesTemplateUrl, "${TAG}", sanitizedVersion) +} + +func buildAwsMarketplaceChangeSet(entityId string, repo string, version string) *marketplacecatalog.Change { + return &marketplacecatalog.Change{ + ChangeType: aws.String("AddDeliveryOptions"), + Entity: &marketplacecatalog.Entity{ + Type: aws.String("ContainerProduct@1.0"), + Identifier: aws.String(entityId), + }, + Details: aws.String(buildAwsMarketplaceVersionDetails(repo, version)), + } +} + +func buildAwsMarketplaceVersionDetails(repo string, version string) string { + releaseNotesUrl := buildAwsMarketplaceReleaseNotesUrl(version) + return fmt.Sprintf(`{ + "Version": { + "ReleaseNotes": "Release notes are available on the website %s", + "VersionTitle": "v%s" + }, + "DeliveryOptions": [ + { + "Details": { + "EcrDeliveryOptionDetails": { + "DeploymentResources": [ + { + "Name": "Helm Charts", + "Url": "%s" + } + ], + "CompatibleServices": ["EKS", "ECS", "ECS-Anywhere", "EKS-Anywhere"], + "ContainerImages": ["%s/%s:%s"], + "Description": "Grafana Enterprise can be installed using the official Grafana Helm chart repository. The repository is available on Github: %s", + "UsageInstructions": "You can apply your Grafana Enterprise license to a new or existing Grafana Enterprise deployment by updating a configuration setting or environment variable. Your Grafana instance must be deployed on AWS, or have network access to AWS. For more information, see %s" + } + }, + "DeliveryOptionTitle": "Helm Chart" + } + ] + }`, releaseNotesUrl, version, helmChartsUrl, marketplaceRegistryUrl, repo, version, helmChartsUrl, docsUrl) +} diff --git a/pkg/build/cmd/publishaws_test.go b/pkg/build/cmd/publishaws_test.go new file mode 100644 index 00000000000..34274aff92a --- /dev/null +++ b/pkg/build/cmd/publishaws_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "io" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go/service/marketplacecatalog" + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type awsPublishTestCase struct { + name string + args []string + expectedError error + errorContains string + expectedOutput string + mockedService *AwsMarketplacePublishingService +} + +func TestPublishAwsMarketplace(t *testing.T) { + t.Setenv("DRONE_BUILD_EVENT", "promote") + testApp := setupPublishAwsMarketplaceTests(t) + errShouldNotCallMock := errors.New("shouldn't call") + + testCases := []awsPublishTestCase{ + { + name: "try to publish without required flags", + errorContains: `Required flags "image, repo, product" not set`, + }, + { + name: "try to publish without credentials", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{ + GetAuthorizationTokenWithContextError: credentials.ErrNoValidProvidersFoundInChain, + }, + }, + expectedError: credentials.ErrNoValidProvidersFoundInChain, + }, + { + name: "try to publish with valid credentials and nonexisting version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{}, + mkt: &mockAwsMarketplaceCatalog{}, + }, + expectedOutput: "Releasing to product", + }, + { + name: "try to publish with valid credentials and existing version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{}, + mkt: &mockAwsMarketplaceCatalog{}, + }, + expectedOutput: "Releasing to product", + }, + { + name: "dry run with invalid credentials", + args: []string{"--dry-run", "--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{ + GetAuthorizationTokenWithContextError: credentials.ErrNoValidProvidersFoundInChain, + }, + }, + expectedError: credentials.ErrNoValidProvidersFoundInChain, + }, + { + name: "dry run with valid credentials", + args: []string{"--dry-run", "--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{ + ImagePushError: errShouldNotCallMock, + }, + mkt: &mockAwsMarketplaceCatalog{ + StartChangeSetWithContextError: errShouldNotCallMock, + }, + }, + expectedOutput: "Dry-Run: Releasing to product", + }, + } + + if os.Getenv("DRONE_COMMIT") == "" { + // this test only works locally due to Drone environment + testCases = append(testCases, + awsPublishTestCase{ + name: "try to publish without version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test"}, + expectedError: errEmptyVersion, + }, + ) + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), publishAwsMarketplaceTestKey, test.mockedService) + args := []string{"run"} + args = append(args, test.args...) + out, err := captureStdout(t, func() error { + return testApp.RunContext(ctx, args) + }) + if test.expectedOutput != "" { + assert.Contains(t, out, test.expectedOutput) + } + if test.expectedError != nil || test.errorContains != "" { + assert.Error(t, err) + if test.expectedError != nil { + assert.ErrorIs(t, err, test.expectedError) + } + if test.errorContains != "" { + assert.ErrorContains(t, err, test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func setupPublishAwsMarketplaceTests(t *testing.T) *cli.App { + t.Helper() + testApp := cli.NewApp() + testApp.Action = PublishAwsMarketplace + testApp.Flags = []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "version", + Usage: "Release version (default from metadata)", + }, + &cli.StringFlag{ + Name: "image", + Required: true, + Usage: "Name of the image to be released", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "AWS Marketplace ECR repository", + }, + &cli.StringFlag{ + Name: "product", + Required: true, + Usage: "AWS Marketplace product identifier", + }, + } + return testApp +} + +type mockAwsMarketplaceDocker struct { + ImagePullError error + ImageTagError error + ImagePushError error +} + +func (m *mockAwsMarketplaceDocker) ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePullError +} +func (m *mockAwsMarketplaceDocker) ImageTag(ctx context.Context, source string, target string) error { + return m.ImageTagError +} +func (m *mockAwsMarketplaceDocker) ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePushError +} + +type mockAwsMarketplaceRegistry struct { + GetAuthorizationTokenWithContextError error +} + +func (m *mockAwsMarketplaceRegistry) GetAuthorizationTokenWithContext(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...request.Option) (*ecr.GetAuthorizationTokenOutput, error) { + return &ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []*ecr.AuthorizationData{ + { + AuthorizationToken: aws.String(base64.StdEncoding.EncodeToString([]byte("username:password"))), + }, + }, + }, m.GetAuthorizationTokenWithContextError +} + +type mockAwsMarketplaceCatalog struct { + DescribeEntityWithContextError error + StartChangeSetWithContextError error +} + +func (m *mockAwsMarketplaceCatalog) DescribeEntityWithContext(ctx context.Context, input *marketplacecatalog.DescribeEntityInput, opts ...request.Option) (*marketplacecatalog.DescribeEntityOutput, error) { + return &marketplacecatalog.DescribeEntityOutput{ + EntityIdentifier: aws.String("productid"), + }, m.DescribeEntityWithContextError +} +func (m *mockAwsMarketplaceCatalog) StartChangeSetWithContext(ctx context.Context, input *marketplacecatalog.StartChangeSetInput, opts ...request.Option) (*marketplacecatalog.StartChangeSetOutput, error) { + return &marketplacecatalog.StartChangeSetOutput{}, m.StartChangeSetWithContextError +} diff --git a/pkg/build/cmd/publishgithub.go b/pkg/build/cmd/publishgithub.go index 02c06b3ab33..5217b16c98a 100644 --- a/pkg/build/cmd/publishgithub.go +++ b/pkg/build/cmd/publishgithub.go @@ -39,9 +39,9 @@ var ( errReleaseNotFound = errors.New(`release not found, use "--create" to create the release`) ) -func PublishGitHub(ctx *cli.Context) error { +func PublishGithub(ctx *cli.Context) error { token := os.Getenv("GH_TOKEN") - f, err := getFlags(ctx) + f, err := getPublishGithubFlags(ctx) if err != nil { return err } @@ -55,7 +55,7 @@ func PublishGitHub(ctx *cli.Context) error { } if f.dryRun { - return runDryRun(f, token, ctx) + return runPublishGithubDryRun(f, token, ctx) } client := newGithubClient(ctx.Context, token) @@ -99,7 +99,7 @@ func githubRepositoryClient(ctx context.Context, token string) githubRepositoryS return client.Repositories } -func getFlags(ctx *cli.Context) (*publishGithubFlags, error) { +func getPublishGithubFlags(ctx *cli.Context) (*publishGithubFlags, error) { metadata, err := GenerateMetadata(ctx) if err != nil { return nil, err @@ -126,12 +126,12 @@ func getFlags(ctx *cli.Context) (*publishGithubFlags, error) { }, nil } -func runDryRun(f *publishGithubFlags, token string, ctx *cli.Context) error { +func runPublishGithubDryRun(f *publishGithubFlags, token string, ctx *cli.Context) error { client := newGithubClient(ctx.Context, token) fmt.Println("Dry-Run: Retrieving release on repository by tag") release, res, err := client.GetReleaseByTag(ctx.Context, f.repo.owner, f.repo.name, f.tag) if err != nil && res.StatusCode != 404 { - fmt.Println("Dry-Run: GitHub communication error:\n", err) + fmt.Println("Dry-Run: Github communication error:\n", err) return nil } diff --git a/pkg/build/cmd/publishgithub_test.go b/pkg/build/cmd/publishgithub_test.go index 845e40a9af9..537669a3f1c 100644 --- a/pkg/build/cmd/publishgithub_test.go +++ b/pkg/build/cmd/publishgithub_test.go @@ -21,16 +21,16 @@ type githubPublishTestCases struct { expectedError error errorContains string expectedOutput string - mockedService *mockGitHubRepositoryServiceImpl + mockedService *mockGithubRepositoryServiceImpl } -var mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} +var mockGithubRepositoryService = &mockGithubRepositoryServiceImpl{} func mockGithubRepositoryClient(context.Context, string) githubRepositoryService { - return mockGitHubRepositoryService + return mockGithubRepositoryService } -func TestPublishGitHub(t *testing.T) { +func TestPublishGithub(t *testing.T) { t.Setenv("DRONE_BUILD_EVENT", "promote") testApp, testPath := setupPublishGithubTests(t) mockErrUnauthorized := errors.New("401") @@ -49,21 +49,21 @@ func TestPublishGitHub(t *testing.T) { name: "try to publish with invalid token", token: "invalid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, expectedError: mockErrUnauthorized, }, { name: "try to publish with valid token and nonexisting tag with create disabled", token: "valid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedError: errReleaseNotFound, }, { name: "try to publish with valid token and nonexisting tag with create enabled", token: "valid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, }, { name: "try to publish with valid token and existing tag", @@ -74,21 +74,21 @@ func TestPublishGitHub(t *testing.T) { name: "dry run with invalid token", token: "invalid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, - expectedOutput: "GitHub communication error", + mockedService: &mockGithubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + expectedOutput: "Github communication error", }, { name: "dry run with valid token and nonexisting tag with create disabled", token: "valid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedOutput: "Release doesn't exist", }, { name: "dry run with valid token and nonexisting tag with create enabled", token: "valid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedOutput: "Would upload asset", }, { @@ -116,9 +116,9 @@ func TestPublishGitHub(t *testing.T) { t.Setenv("GH_TOKEN", test.token) } if test.mockedService != nil { - mockGitHubRepositoryService = test.mockedService + mockGithubRepositoryService = test.mockedService } else { - mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} + mockGithubRepositoryService = &mockGithubRepositoryServiceImpl{} } args := []string{"run"} args = append(args, test.args...) @@ -154,7 +154,7 @@ func setupPublishGithubTests(t *testing.T) (*cli.App, string) { newGithubClient = mockGithubRepositoryClient testApp := cli.NewApp() - testApp.Action = PublishGitHub + testApp.Action = PublishGithub testApp.Flags = []cli.Flag{ &dryRunFlag, &cli.StringFlag{ @@ -165,7 +165,7 @@ func setupPublishGithubTests(t *testing.T) (*cli.App, string) { &cli.StringFlag{ Name: "repo", Required: true, - Usage: "GitHub repository", + Usage: "Github repository", }, &cli.StringFlag{ Name: "tag", @@ -194,13 +194,13 @@ func captureStdout(t *testing.T, fn func() error) (string, error) { return string(out), err } -type mockGitHubRepositoryServiceImpl struct { +type mockGithubRepositoryServiceImpl struct { tagErr error createErr error uploadErr error } -func (m *mockGitHubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) { var release *github.RepositoryRelease res := &github.Response{Response: &http.Response{}} if m.tagErr == nil { @@ -212,12 +212,12 @@ func (m *mockGitHubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, o return release, res, m.tagErr } -func (m *mockGitHubRepositoryServiceImpl) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { releaseID := int64(1) return &github.RepositoryRelease{ID: &releaseID}, &github.Response{}, m.createErr } -func (m *mockGitHubRepositoryServiceImpl) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { assetName := "test" assetUrl := "testurl.com.br" return &github.ReleaseAsset{Name: &assetName, BrowserDownloadURL: &assetUrl}, &github.Response{}, m.uploadErr diff --git a/pkg/build/cmd/publishimages_enterprise2.go b/pkg/build/cmd/publishimages_enterprise2.go new file mode 100644 index 00000000000..80ca4188684 --- /dev/null +++ b/pkg/build/cmd/publishimages_enterprise2.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/docker" + "github.com/grafana/grafana/pkg/build/gcloud" + "github.com/urfave/cli/v2" +) + +func Enterprise2(c *cli.Context) error { + if c.NArg() > 0 { + if err := cli.ShowSubcommandHelp(c); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return cli.NewExitError("", 1) + } + + if err := gcloud.ActivateServiceAccount(); err != nil { + return fmt.Errorf("couldn't activate service account, err: %w", err) + } + + metadata, err := GenerateMetadata(c) + if err != nil { + return err + } + + buildConfig, err := config.GetBuildConfig(metadata.ReleaseMode.Mode) + if err != nil { + return err + } + + cfg := docker.Config{ + Archs: buildConfig.Docker.Architectures, + Distribution: buildConfig.Docker.Distribution, + DockerHubRepo: c.String("dockerhub-repo"), + Tag: metadata.GrafanaVersion, + } + + err = dockerLoginEnterprise2() + if err != nil { + return err + } + + var distributionStr []string + for _, distribution := range cfg.Distribution { + switch distribution { + case alpine: + distributionStr = append(distributionStr, "") + case ubuntu: + distributionStr = append(distributionStr, "-ubuntu") + default: + return fmt.Errorf("unrecognized distribution %q", distribution) + } + } + + for _, distribution := range distributionStr { + var imageFileNames []string + for _, arch := range cfg.Archs { + imageFilename := fmt.Sprintf("%s:%s%s-%s", cfg.DockerHubRepo, cfg.Tag, distribution, arch) + err := docker.PushImage(imageFilename) + if err != nil { + return err + } + imageFileNames = append(imageFileNames, imageFilename) + } + manifest := fmt.Sprintf("%s:%s%s", cfg.DockerHubRepo, cfg.Tag, distribution) + args := []string{"manifest", "create", manifest} + args = append(args, imageFileNames...) + + //nolint:gosec + cmd := exec.Command("docker", args...) + cmd.Env = append(os.Environ(), "DOCKER_CLI_EXPERIMENTAL=enabled") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create Docker manifest: %w\n%s", err, output) + } + + err = docker.PushManifest(manifest) + if err != nil { + return err + } + } + + return nil +} + +func dockerLoginEnterprise2() error { + log.Println("Docker login...") + cmd := exec.Command("gcloud", "auth", "configure-docker") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("error logging in to DockerHub: %s %q", out, err) + } + + log.Println("Successful login!") + return nil +} diff --git a/pkg/build/cmd/uploadcdn.go b/pkg/build/cmd/uploadcdn.go new file mode 100644 index 00000000000..e9c4b72acab --- /dev/null +++ b/pkg/build/cmd/uploadcdn.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/gcloud/storage" + "github.com/urfave/cli/v2" +) + +// UploadCDN implements the sub-command "upload-cdn". +func UploadCDN(c *cli.Context) error { + if c.NArg() > 0 { + if err := cli.ShowSubcommandHelp(c); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return cli.NewExitError("", 1) + } + + metadata, err := GenerateMetadata(c) + if err != nil { + return err + } + + version := metadata.GrafanaVersion + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + buildConfig, err := config.GetBuildConfig(metadata.ReleaseMode.Mode) + if err != nil { + return err + } + + edition := os.Getenv("EDITION") + log.Printf("Uploading Grafana CDN Assets, version %s, %s edition...", version, edition) + + editionPath := "" + + switch config.Edition(edition) { + case config.EditionOSS: + editionPath = "grafana-oss" + case config.EditionEnterprise: + editionPath = "grafana" + case config.EditionEnterprise2: + editionPath = os.Getenv("ENTERPRISE2_CDN_PATH") + default: + panic(fmt.Sprintf("unrecognized edition %q", edition)) + } + + gcs, err := storage.New() + if err != nil { + return err + } + + bucket := gcs.Bucket(buildConfig.Buckets.CDNAssets) + srcPath := buildConfig.Buckets.CDNAssetsDir + srcPath = filepath.Join(srcPath, editionPath, version) + + if err := gcs.DeleteDir(c.Context, bucket, srcPath); err != nil { + return err + } + log.Printf("Successfully cleaned source: %s/%s\n", buildConfig.Buckets.CDNAssets, srcPath) + + if err := gcs.CopyLocalDir(c.Context, "./public", bucket, srcPath, false); err != nil { + return err + } + + log.Printf("Successfully uploaded cdn static assets to: %s/%s!\n", buildConfig.Buckets.CDNAssets, srcPath) + + return nil +} diff --git a/pkg/build/cmd/uploadpackages.go b/pkg/build/cmd/uploadpackages.go new file mode 100644 index 00000000000..01a43a43bdf --- /dev/null +++ b/pkg/build/cmd/uploadpackages.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/base64" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/gcloud" + "github.com/grafana/grafana/pkg/build/packaging" + "github.com/urfave/cli/v2" +) + +const releaseFolder = "release" +const mainFolder = "main" +const releaseBranchFolder = "prerelease" + +type uploadConfig struct { + config.Config + + edition config.Edition + versionMode config.VersionMode + gcpKey string + distDir string +} + +// UploadPackages implements the sub-command "upload-packages". +func UploadPackages(c *cli.Context) error { + if c.NArg() > 0 { + if err := cli.ShowSubcommandHelp(c); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return cli.NewExitError("", 1) + } + + gcpKeyB64 := strings.TrimSpace(os.Getenv("GCP_KEY")) + if gcpKeyB64 == "" { + return cli.NewExitError("the environment variable GCP_KEY must be set", 1) + } + gcpKeyB, err := base64.StdEncoding.DecodeString(gcpKeyB64) + if err != nil { + return cli.NewExitError("failed to base64 decode $GCP_KEY", 1) + } + gcpKey := string(gcpKeyB) + + distDir, err := filepath.Abs("dist") + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + metadata, err := GenerateMetadata(c) + if err != nil { + return err + } + + version := metadata.GrafanaVersion + + releaseMode, err := metadata.GetReleaseMode() + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + releaseModeConfig, err := config.GetBuildConfig(releaseMode.Mode) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + edition, ok := os.LookupEnv("EDITION") + if !ok { + return fmt.Errorf("EDITION envvar is missing, exitting") + } + + // TODO: Verify config values + cfg := uploadConfig{ + Config: config.Config{ + Version: version, + Bucket: releaseModeConfig.Buckets.Artifacts, + }, + edition: config.Edition(edition), + versionMode: releaseMode.Mode, + gcpKey: gcpKey, + distDir: distDir, + } + + if cfg.edition == config.EditionEnterprise2 { + if releaseModeConfig.Buckets.ArtifactsEnterprise2 != "" { + cfg.Config.Bucket = releaseModeConfig.Buckets.ArtifactsEnterprise2 + } else { + return fmt.Errorf("enterprise2 bucket var doesn't exist") + } + } + + if err := uploadPackages(cfg); err != nil { + return cli.NewExitError(err.Error(), 1) + } + + log.Println("Successfully uploaded packages!") + return nil +} + +func uploadPackages(cfg uploadConfig) error { + log.Printf("Uploading Grafana packages, version %s, %s edition, %s mode...\n", cfg.Version, cfg.edition, + cfg.versionMode) + + if err := gcloud.ActivateServiceAccount(); err != nil { + return fmt.Errorf("couldn't activate service account, err: %w", err) + } + + edition := strings.ToLower(string(cfg.edition)) + + var sfx string + switch cfg.edition { + case config.EditionOSS: + case config.EditionEnterprise: + sfx = "-enterprise" + case config.EditionEnterprise2: + sfx = "-enterprise2" + default: + panic(fmt.Sprintf("unrecognized edition %q", cfg.edition)) + } + matches, err := filepath.Glob(filepath.Join(cfg.distDir, fmt.Sprintf("grafana%s*", sfx))) + if err != nil { + return fmt.Errorf("failed to list packages: %w", err) + } + fpaths := []string{} + rePkg := packaging.PackageRegexp(cfg.edition) + for _, fpath := range matches { + fname := filepath.Base(fpath) + if strings.Contains(fname, "latest") || !rePkg.MatchString(fname) { + log.Printf("Ignoring file %q\n", fpath) + continue + } + + fpaths = append(fpaths, fpath) + } + + var versionFolder string + switch cfg.versionMode { + case config.TagMode: + versionFolder = releaseFolder + case config.MainMode, config.CustomMode: + versionFolder = mainFolder + case config.ReleaseBranchMode: + versionFolder = releaseBranchFolder + default: + panic(fmt.Sprintf("Unrecognized version mode: %s", cfg.versionMode)) + } + + var tag, gcsPath string + droneTag := strings.TrimSpace(os.Getenv("DRONE_TAG")) + if droneTag != "" { + tag = droneTag + gcsPath = fmt.Sprintf("gs://%s/%s/%s/%s", cfg.Bucket, tag, edition, versionFolder) + } else { + gcsPath = fmt.Sprintf("gs://%s/%s/%s/", cfg.Bucket, edition, versionFolder) + } + log.Printf("Uploading %d file(s) to GCS (%s)...\n", len(fpaths), gcsPath) + + args := []string{"-m", "cp"} + args = append(args, fpaths...) + args = append(args, gcsPath) + cmd := exec.Command("gsutil", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to upload files to GCS: %s", output) + } + + return nil +} diff --git a/pkg/build/config/config.go b/pkg/build/config/config.go index 2f7dbe7358f..7c8b4b133bf 100644 --- a/pkg/build/config/config.go +++ b/pkg/build/config/config.go @@ -16,4 +16,5 @@ type Config struct { PullEnterprise bool NetworkConcurrency bool PackageVersion string + SignPackages bool } diff --git a/pkg/build/docker/build.go b/pkg/build/docker/build.go index f77d54cd43e..7267d4a094b 100644 --- a/pkg/build/docker/build.go +++ b/pkg/build/docker/build.go @@ -85,6 +85,11 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use var additionalDockerRepo string var tags []string var imageFileBase string + var dockerEnterprise2Repo string + if repo, ok := os.LookupEnv("DOCKER_ENTERPRISE2_REPO"); ok { + dockerEnterprise2Repo = repo + } + switch edition { case config.EditionOSS: dockerRepo = "grafana/grafana-image-tags" @@ -94,6 +99,10 @@ func BuildImage(version string, arch config.Architecture, grafanaDir string, use dockerRepo = "grafana/grafana-enterprise-image-tags" imageFileBase = "grafana-enterprise" editionStr = "-enterprise" + case config.EditionEnterprise2: + dockerRepo = dockerEnterprise2Repo + imageFileBase = "grafana-enterprise2" + editionStr = "-enterprise2" default: return []string{}, fmt.Errorf("unrecognized edition %s", edition) } diff --git a/pkg/build/docker/push.go b/pkg/build/docker/push.go new file mode 100644 index 00000000000..da87ef2cb16 --- /dev/null +++ b/pkg/build/docker/push.go @@ -0,0 +1,62 @@ +package docker + +import ( + "fmt" + "log" + "os" + "os/exec" + "time" +) + +const ( + tries = 3 + sleepTime = 30 +) + +func PushImage(newImage string) error { + var err error + for i := 0; i < tries; i++ { + log.Printf("push attempt #%d...", i+1) + var out []byte + cmd := exec.Command("docker", "push", newImage) + cmd.Dir = "." + out, err = cmd.CombinedOutput() + if err != nil { + log.Printf("output: %s", out) + log.Printf("sleep for %d, before retrying...", sleepTime) + time.Sleep(sleepTime * time.Second) + } else { + log.Printf("Successfully pushed %s!", newImage) + break + } + } + if err != nil { + return fmt.Errorf("error pushing images to DockerHub: %q", err) + } + return nil +} + +func PushManifest(manifest string) error { + log.Printf("Pushing Docker manifest %s...", manifest) + + var err error + for i := 0; i < tries; i++ { + log.Printf("push attempt #%d...", i+1) + var out []byte + cmd := exec.Command("docker", "manifest", "push", manifest) + cmd.Env = append(os.Environ(), "DOCKER_CLI_EXPERIMENTAL=enabled") + out, err = cmd.CombinedOutput() + if err != nil { + log.Printf("output: %s", out) + log.Printf("sleep for %d, before retrying...", sleepTime) + time.Sleep(sleepTime * time.Second) + } else { + log.Printf("Successful manifest push! %s", string(out)) + break + } + } + if err != nil { + return fmt.Errorf("failed to push manifest, err: %w", err) + } + return nil +} diff --git a/pkg/build/grafana/build.go b/pkg/build/grafana/build.go index b8fa022c396..4d661e8d851 100644 --- a/pkg/build/grafana/build.go +++ b/pkg/build/grafana/build.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/build/golangutils" ) -var binaries = []string{"grafana-server", "grafana-cli"} +var binaries = []string{"grafana", "grafana-server", "grafana-cli"} const ( SuffixEnterprise2 = "-enterprise2" diff --git a/pkg/build/packaging/grafana.go b/pkg/build/packaging/grafana.go index 6d48dbb8b57..84e083c3844 100644 --- a/pkg/build/packaging/grafana.go +++ b/pkg/build/packaging/grafana.go @@ -75,9 +75,13 @@ func PackageGrafana( if err := packageGrafana(ctx, edition, version, grafanaDir, variants, shouldSign, p); err != nil { return err } - if err := signRPMPackages(edition, cfg, grafanaDir); err != nil { - return err + + if cfg.SignPackages { + if err := signRPMPackages(edition, cfg, grafanaDir); err != nil { + return err + } } + if err := checksumPackages(grafanaDir, edition); err != nil { return err } @@ -282,6 +286,7 @@ func shaFile(fpath string) error { // createPackage creates a Linux package. func createPackage(srcDir string, options linuxPackageOptions) error { + binary := "grafana" cliBinary := "grafana-cli" serverBinary := "grafana-server" @@ -310,10 +315,15 @@ func createPackage(srcDir string, options linuxPackageOptions) error { } } - if err := fs.CopyFile(options.cliBinaryWrapperSrc, filepath.Join(packageRoot, "usr", "sbin", cliBinary)); err != nil { + if err := fs.CopyFile(filepath.Join(options.wrapperFilePath, binary), + filepath.Join(packageRoot, "usr", "sbin", binary)); err != nil { return err } - if err := fs.CopyFile(filepath.Join(srcDir, "bin", serverBinary), + if err := fs.CopyFile(filepath.Join(options.wrapperFilePath, cliBinary), + filepath.Join(packageRoot, "usr", "sbin", cliBinary)); err != nil { + return err + } + if err := fs.CopyFile(filepath.Join(options.wrapperFilePath, serverBinary), filepath.Join(packageRoot, "usr", "sbin", serverBinary)); err != nil { return err } @@ -329,19 +339,17 @@ func createPackage(srcDir string, options linuxPackageOptions) error { if err := fs.CopyRecursive(srcDir, filepath.Join(packageRoot, options.homeDir)); err != nil { return err } - homeBinDir := filepath.Join(packageRoot, options.homeBinDir) - if err := os.RemoveAll(homeBinDir); err != nil { - return fmt.Errorf("failed to remove %q: %w", homeBinDir, err) - } - //nolint - if err := os.MkdirAll(homeBinDir, 0o755); err != nil { - return fmt.Errorf("failed to make directory %q: %w", homeBinDir, err) - } - // The grafana-cli binary is exposed through a wrapper to ensure a proper - // configuration is in place. To enable that, we need to store the original - // binary in a separate location to avoid conflicts. - if err := fs.CopyFile(filepath.Join(srcDir, "bin", cliBinary), filepath.Join(homeBinDir, cliBinary)); err != nil { - return err + + // remove unneeded binaries, these are exposed via wrappers that provide the needed configuration + for _, fileName := range []string{ + cliBinary, + cliBinary + ".md5", + serverBinary, + serverBinary + ".md5", + } { + if err := os.Remove(filepath.Join(packageRoot, options.homeBinDir, fileName)); err != nil { + return fmt.Errorf("failed to remove %q: %w", filepath.Join(options.homeBinDir, fileName), err) + } } if err := executeFPM(options, packageRoot, srcDir); err != nil { @@ -475,43 +483,6 @@ func copyBinaries(grafanaDir, tmpDir string, args grafana.BuildArgs, edition con return nil } -// copyScripts copies scripts from grafanaDir into tmpDir. -func copyScripts(grafanaDir, tmpDir string) error { - //nolint - if err := os.MkdirAll(filepath.Join(tmpDir, "scripts"), 0o755); err != nil { - return fmt.Errorf("failed to create dir %q: %w", filepath.Join(tmpDir, "scripts"), err) - } - scriptsDir := filepath.Join(grafanaDir, "scripts") - infos, err := os.ReadDir(scriptsDir) - if err != nil { - return fmt.Errorf("failed to list files in %q: %w", scriptsDir, err) - } - for _, file := range infos { - info, err := file.Info() - if err != nil { - return err - } - - if info.IsDir() { - continue - } - - if info.Mode()&os.ModeSymlink != 0 { - continue - } - - path := "" - - path = filepath.Join(scriptsDir, info.Name()) - - if err := fs.CopyFile(path, filepath.Join(tmpDir, "scripts", info.Name())); err != nil { - return fmt.Errorf("failed to copy %q to %q: %w", path, tmpDir, err) - } - } - - return nil -} - // copyConfFiles copies configuration files from grafanaDir into tmpDir. func copyConfFiles(grafanaDir, tmpDir string) error { //nolint:gosec @@ -717,9 +688,6 @@ func realPackageVariant(ctx context.Context, v config.Variant, edition config.Ed if err := copyBinaries(grafanaDir, tmpDir, args, edition); err != nil { return err } - if err := copyScripts(grafanaDir, tmpDir); err != nil { - return err - } if err := copyConfFiles(grafanaDir, tmpDir); err != nil { return err } @@ -773,7 +741,7 @@ func realPackageVariant(ctx context.Context, v config.Variant, edition config.Ed initdScriptSrc: filepath.Join(grafanaDir, "packaging", "deb", "init.d", "grafana-server"), defaultFileSrc: filepath.Join(grafanaDir, "packaging", "deb", "default", "grafana-server"), systemdFileSrc: filepath.Join(grafanaDir, "packaging", "deb", "systemd", "grafana-server.service"), - cliBinaryWrapperSrc: filepath.Join(grafanaDir, "packaging", "wrappers", "grafana-cli"), + wrapperFilePath: filepath.Join(grafanaDir, "packaging", "wrappers"), depends: []string{"adduser", "libfontconfig1"}, }); err != nil { return err @@ -807,7 +775,7 @@ func realPackageVariant(ctx context.Context, v config.Variant, edition config.Ed initdScriptSrc: filepath.Join(grafanaDir, "packaging", "rpm", "init.d", "grafana-server"), defaultFileSrc: filepath.Join(grafanaDir, "packaging", "rpm", "sysconfig", "grafana-server"), systemdFileSrc: filepath.Join(grafanaDir, "packaging", "rpm", "systemd", "grafana-server.service"), - cliBinaryWrapperSrc: filepath.Join(grafanaDir, "packaging", "wrappers", "grafana-cli"), + wrapperFilePath: filepath.Join(grafanaDir, "packaging", "wrappers"), // chkconfig is depended on since our systemd service wraps a SysV init script, and that requires chkconfig depends: []string{"/sbin/service", "chkconfig", "fontconfig", "freetype", "urw-fonts"}, }); err != nil { @@ -889,7 +857,7 @@ type linuxPackageOptions struct { initdScriptSrc string defaultFileSrc string systemdFileSrc string - cliBinaryWrapperSrc string + wrapperFilePath string depends []string } @@ -929,7 +897,7 @@ func createZip(srcDir, version, variantStr, sfx, grafanaDir string) error { return fmt.Errorf("failed to create %q: %w", fpath, err) } defer func() { - if err := tgt.Close(); err != nil { + if err := tgt.Close(); err != nil && !errors.Is(err, os.ErrClosed) { log.Println(err) } }() @@ -1045,7 +1013,7 @@ func createTarball(srcDir, version, variantStr, sfx, grafanaDir string) error { return fmt.Errorf("failed to create %q: %w", fpath, err) } defer func() { - if err := tgt.Close(); err != nil { + if err := tgt.Close(); err != nil && !errors.Is(err, os.ErrClosed) { log.Println(err) } }() diff --git a/pkg/cmd/grafana-cli/commands/cli.go b/pkg/cmd/grafana-cli/commands/cli.go index 17a2f1258d4..e9a77d97ce3 100644 --- a/pkg/cmd/grafana-cli/commands/cli.go +++ b/pkg/cmd/grafana-cli/commands/cli.go @@ -1,11 +1,9 @@ package commands import ( - "fmt" "os" "runtime" - "github.com/fatih/color" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" @@ -13,18 +11,10 @@ import ( ) // RunCLI is the entrypoint for the grafana-cli command. It returns the exit code for the grafana-cli program. -func RunCLI(version string) int { - setupLogging() - - app := &cli.App{ - Name: "Grafana CLI", - Authors: []*cli.Author{ - { - Name: "Grafana Project", - Email: "hello@grafana.com", - }, - }, - Version: version, +func CLICommand(version string) *cli.Command { + return &cli.Command{ + Name: "cli", + Usage: "run the grafana cli", Flags: []cli.Flag{ &cli.StringFlag{ Name: "pluginsDir", @@ -64,39 +54,19 @@ func RunCLI(version string) int { Name: "config", Usage: "Path to config file", }, + cli.VersionFlag, }, - Commands: Commands, - CommandNotFound: cmdNotFound, - } + Subcommands: Commands, + Before: func(c *cli.Context) error { + // backward-compatible handling for cli version flag + if c.Bool("version") { + cli.ShowVersion(c) + os.Exit(0) + } - app.Before = func(c *cli.Context) error { - services.Init(version, c.Bool("insecure"), c.Bool("debug")) - return nil - } - - if err := app.Run(os.Args); err != nil { - logger.Errorf("%s: %s %s\n", color.RedString("Error"), color.RedString("✗"), err) - return 1 - } - - return 0 -} - -func setupLogging() { - for _, f := range os.Args { - if f == "-d" || f == "--debug" || f == "-debug" { - logger.SetDebug(true) - } + logger.SetDebug(c.Bool("debug")) + services.Init(version, c.Bool("insecure"), c.Bool("debug")) + return nil + }, } } - -func cmdNotFound(c *cli.Context, command string) { - fmt.Printf( - "%s: '%s' is not a %s command. See '%s --help'.\n", - c.App.Name, - command, - c.App.Name, - os.Args[0], - ) - os.Exit(1) -} diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 0b7ef9461d7..a4edda3aabe 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -78,7 +78,8 @@ func initCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, error) { cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ Config: cmd.ConfigFile(), HomePath: cmd.HomePath(), - Args: append(configOptions, cmd.Args().Slice()...), // tailing arguments have precedence over the options string + // tailing arguments have precedence over the options string + Args: append(configOptions, cmd.Args().Slice()...), }) if err != nil { diff --git a/pkg/cmd/grafana-cli/main.go b/pkg/cmd/grafana-cli/main.go index 3f300aec687..af5636c210d 100644 --- a/pkg/cmd/grafana-cli/main.go +++ b/pkg/cmd/grafana-cli/main.go @@ -3,12 +3,9 @@ package main import ( "os" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands" + "github.com/grafana/grafana/pkg/util/cmd" ) -// Version is overridden by build flags -var version = "main" - func main() { - os.Exit(commands.RunCLI(version)) + os.Exit(cmd.RunGrafanaCmd("cli")) } diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 82efb1ce69e..3276c5cc6ff 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -51,7 +51,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/comments" @@ -83,6 +82,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfodatabase "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/loginattempt" "github.com/grafana/grafana/pkg/services/loginattempt/loginattemptimpl" "github.com/grafana/grafana/pkg/services/ngalert" ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -128,7 +128,6 @@ import ( "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/services/userauth/userauthimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/cloudmonitoring" @@ -229,6 +228,7 @@ var wireSet = wire.NewSet( loginpkg.ProvideService, wire.Bind(new(loginpkg.Authenticator), new(*loginpkg.AuthenticatorService)), loginattemptimpl.ProvideService, + wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), datasourceproxy.ProvideService, search.ProvideService, searchV2.ProvideService, @@ -253,8 +253,6 @@ var wireSet = wire.NewSet( influxdb.ProvideService, wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, - auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, @@ -326,7 +324,6 @@ var wireSet = wire.NewSet( userimpl.ProvideService, orgimpl.ProvideService, teamimpl.ProvideService, - userauthimpl.ProvideService, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, objectdummyserver.ProvideFakeObjectServer, diff --git a/pkg/cmd/grafana-cli/runner/wireexts_oss.go b/pkg/cmd/grafana-cli/runner/wireexts_oss.go index 407ce3c1945..dfc36b3cd26 100644 --- a/pkg/cmd/grafana-cli/runner/wireexts_oss.go +++ b/pkg/cmd/grafana-cli/runner/wireexts_oss.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" @@ -48,9 +49,9 @@ var wireExtsSet = wire.NewSet( wire.Bind(new(setting.Provider), new(*setting.OSSImpl)), osskmsproviders.ProvideService, wire.Bind(new(kmsproviders.Service), new(osskmsproviders.Service)), - auth.ProvideUserAuthTokenService, - wire.Bind(new(models.UserTokenService), new(*auth.UserAuthTokenService)), - wire.Bind(new(models.UserTokenBackgroundService), new(*auth.UserAuthTokenService)), + authimpl.ProvideUserAuthTokenService, + wire.Bind(new(auth.UserTokenService), new(*authimpl.UserAuthTokenService)), + wire.Bind(new(auth.UserTokenBackgroundService), new(*authimpl.UserAuthTokenService)), acimpl.ProvideService, wire.Bind(new(accesscontrol.Service), new(*acimpl.Service)), wire.Bind(new(accesscontrol.RoleRegistry), new(*acimpl.Service)), diff --git a/pkg/cmd/grafana-server/commands/cli.go b/pkg/cmd/grafana-server/commands/cli.go index ab1bf653e97..10ffd4668b2 100644 --- a/pkg/cmd/grafana-server/commands/cli.go +++ b/pkg/cmd/grafana-server/commands/cli.go @@ -13,6 +13,7 @@ import ( "runtime/debug" "runtime/trace" "strconv" + "strings" "syscall" "time" @@ -32,6 +33,7 @@ type ServerOptions struct { Commit string BuildBranch string BuildStamp string + Args []string } type exitWithCode struct { @@ -54,6 +56,8 @@ func RunServer(opt ServerOptions) int { pidFile = serverFs.String("pidfile", "", "path to pid file") packaging = serverFs.String("packaging", "unknown", "describes the way Grafana was installed") + configOverrides = serverFs.String("configOverrides", "", "Configuration options to override defaults as a string. e.g. cfg:default.paths.log=/dev/null") + v = serverFs.Bool("v", false, "prints current version and exits") vv = serverFs.Bool("vv", false, "prints current version, all dependencies and exits") profile = serverFs.Bool("profile", false, "Turn on pprof profiling") @@ -63,7 +67,7 @@ func RunServer(opt ServerOptions) int { tracingFile = serverFs.String("tracing-file", "trace.out", "Define tracing output file") ) - if err := serverFs.Parse(os.Args[1:]); err != nil { + if err := serverFs.Parse(opt.Args); err != nil { fmt.Fprintln(os.Stderr, err.Error()) return 1 } @@ -108,7 +112,7 @@ func RunServer(opt ServerOptions) int { }() } - if err := executeServer(*configFile, *homePath, *pidFile, *packaging, traceDiagnostics, opt); err != nil { + if err := executeServer(*configFile, *homePath, *pidFile, *packaging, *configOverrides, traceDiagnostics, opt); err != nil { code := 1 var ewc exitWithCode if errors.As(err, &ewc) { @@ -124,7 +128,7 @@ func RunServer(opt ServerOptions) int { return 0 } -func executeServer(configFile, homePath, pidFile, packaging string, traceDiagnostics *tracingDiagnostics, opt ServerOptions) error { +func executeServer(configFile, homePath, pidFile, packaging, configOverrides string, traceDiagnostics *tracingDiagnostics, opt ServerOptions) error { defer func() { if err := log.Close(); err != nil { fmt.Fprintf(os.Stderr, "Failed to close log: %s\n", err) @@ -185,11 +189,23 @@ func executeServer(configFile, homePath, pidFile, packaging string, traceDiagnos fmt.Println("Grafana server is running with elevated privileges. This is not recommended") } - s, err := server.Initialize(setting.CommandLineArgs{ - Config: configFile, HomePath: homePath, Args: serverFs.Args(), - }, server.Options{ - PidFile: pidFile, Version: opt.Version, Commit: opt.Commit, BuildBranch: opt.BuildBranch, - }, api.ServerOptions{}) + configOptions := strings.Split(configOverrides, " ") + + s, err := server.Initialize( + setting.CommandLineArgs{ + Config: configFile, + HomePath: homePath, + // tailing arguments have precedence over the options string + Args: append(configOptions, serverFs.Args()...), + }, + server.Options{ + PidFile: pidFile, + Version: opt.Version, + Commit: opt.Commit, + BuildBranch: opt.BuildBranch, + }, + api.ServerOptions{}, + ) if err != nil { fmt.Fprintf(os.Stderr, "Failed to start grafana. error: %s\n", err.Error()) return err diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index d615125d74d..8df5e6f7b26 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -3,20 +3,9 @@ package main import ( "os" - "github.com/grafana/grafana/pkg/cmd/grafana-server/commands" + "github.com/grafana/grafana/pkg/util/cmd" ) -// The following variables cannot be constants, since they can be overridden through the -X link flag -var version = "9.2.0" -var commit = "NA" -var buildBranch = "main" -var buildstamp string - func main() { - os.Exit(commands.RunServer(commands.ServerOptions{ - Version: version, - Commit: commit, - BuildBranch: buildBranch, - BuildStamp: buildstamp, - })) + os.Exit(cmd.RunGrafanaCmd("server")) } diff --git a/pkg/cmd/grafana/main.go b/pkg/cmd/grafana/main.go new file mode 100644 index 00000000000..291a0b1cc9f --- /dev/null +++ b/pkg/cmd/grafana/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + + "github.com/fatih/color" + gcli "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands" + gsrv "github.com/grafana/grafana/pkg/cmd/grafana-server/commands" + "github.com/urfave/cli/v2" +) + +// The following variables cannot be constants, since they can be overridden through the -X link flag +var version = "9.2.0" +var commit = "NA" +var buildBranch = "main" +var buildstamp string + +func main() { + app := &cli.App{ + Name: "grafana", + Usage: "Grafana server and command line interface", + Authors: []*cli.Author{ + { + Name: "Grafana Project", + Email: "hello@grafana.com", + }, + }, + Version: version, + Commands: []*cli.Command{ + gcli.CLICommand(version), + { + Name: "server", + Usage: "server ", + Action: func(context *cli.Context) error { + os.Exit(gsrv.RunServer(gsrv.ServerOptions{ + Version: version, + Commit: commit, + BuildBranch: buildBranch, + BuildStamp: buildstamp, + Args: context.Args().Slice(), + })) + return nil + }, + SkipFlagParsing: true, + }, + }, + CommandNotFound: cmdNotFound, + } + + if err := app.Run(os.Args); err != nil { + fmt.Printf("%s: %s %s\n", color.RedString("Error"), color.RedString("✗"), err) + os.Exit(1) + } + + os.Exit(0) +} + +func cmdNotFound(c *cli.Context, command string) { + fmt.Printf( + "%s: '%s' is not a %s command. See '%s --help'.\n", + c.App.Name, + command, + c.App.Name, + os.Args[0], + ) + os.Exit(1) +} diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go index a7b7aed83f8..1a56f71a5b4 100644 --- a/pkg/codegen/generators.go +++ b/pkg/codegen/generators.go @@ -29,12 +29,34 @@ func ForGen(rt *thema.Runtime, decl *kindsys.SomeDecl) (*DeclForGen, error) { }, nil } +// RawForGen produces a [DeclForGen] from a [kindsys.Raw] kind. +// +// Useful for grafana-external code generators, which depend on the Kind +// representation in registries produced by grafana core (such as +// ["github.com/grafana/grafana/pkg/registry/corekind".NewBase]). +func RawForGen(k kindsys.Raw) *DeclForGen { + return &DeclForGen{ + SomeDecl: k.Decl().Some(), + } +} + +// StructuredForGen produces a [DeclForGen] from a [kindsys.Structured] kind. +// +// Useful for grafana-external code generators, which depend on the Kind +// representation in registries produced by grafana core (such as +// ["github.com/grafana/grafana/pkg/registry/corekind".NewBase]). +func StructuredForGen(k kindsys.Structured) *DeclForGen { + return &DeclForGen{ + SomeDecl: k.Decl().Some(), + lin: k.Lineage(), + } +} + // DeclForGen wraps [kindsys.SomeDecl] to provide trivial caching of // the lineage declared by the kind (nil for raw kinds). type DeclForGen struct { *kindsys.SomeDecl lin thema.Lineage - sch thema.Lineage } // Lineage returns the [thema.Lineage] for the underlying [kindsys.SomeDecl]. @@ -42,10 +64,15 @@ func (decl *DeclForGen) Lineage() thema.Lineage { return decl.lin } -// Schema returns the [thema.Schema] that a jenny should operate against, for those -// jennies that target a single schema. -func (decl *DeclForGen) Schema() thema.Lineage { - return decl.sch +// ForLatestSchema returns a [SchemaForGen] for the latest schema in this +// DeclForGen's lineage. +func (decl *DeclForGen) ForLatestSchema() SchemaForGen { + comm := decl.Meta.Common() + return SchemaForGen{ + Name: comm.Name, + Schema: decl.Lineage().Latest(), + IsGroup: comm.LineageIsGroup, + } } // SlashHeaderMapper produces a FileMapper that injects a comment header onto @@ -59,22 +86,28 @@ func SlashHeaderMapper(maingen string) codejen.FileMapper { case ".json", ".yml", ".yaml": return f, nil default: - b := new(bytes.Buffer) - fmt.Fprintf(b, headerTmpl, filepath.ToSlash(maingen), f.FromString()) - fmt.Fprint(b, string(f.Data)) - f.Data = b.Bytes() + buf := new(bytes.Buffer) + if err := tmpls.Lookup("gen_header.tmpl").Execute(buf, tvars_gen_header{ + MainGenerator: maingen, + Using: f.From, + }); err != nil { + return codejen.File{}, fmt.Errorf("failed executing gen header template: %w", err) + } + fmt.Fprint(buf, string(f.Data)) + f.Data = buf.Bytes() } return f, nil } } -var headerTmpl = `// THIS FILE IS GENERATED. EDITING IS FUTILE. -// -// Generated by: -// %s -// Using jennies: -// %s -// -// Run 'make gen-cue' from repository root to regenerate. - -` +// SchemaForGen is an intermediate values type for jennies that holds both a thema.Schema, +// and values relevant to generating the schema that should properly, eventually, be in +// thema itself. +type SchemaForGen struct { + // The PascalCase name of the schematized type. + Name string + // The schema to be rendered for the type itself. + Schema thema.Schema + // Whether the schema is grouped. See https://github.com/grafana/thema/issues/62 + IsGroup bool +} diff --git a/pkg/codegen/jenny_eachmajor.go b/pkg/codegen/jenny_eachmajor.go new file mode 100644 index 00000000000..766fcad5bf0 --- /dev/null +++ b/pkg/codegen/jenny_eachmajor.go @@ -0,0 +1,74 @@ +package codegen + +import ( + "fmt" + "path/filepath" + + "github.com/grafana/codejen" + "github.com/grafana/grafana/pkg/kindsys" +) + +// LatestMajorsOrXJenny returns a jenny that repeats the input for the latest in each major version, +func LatestMajorsOrXJenny(parentdir string, inner codejen.OneToOne[SchemaForGen]) OneToMany { + if inner == nil { + panic("inner jenny must not be nil") + } + + return &lmox{ + parentdir: parentdir, + inner: inner, + } +} + +type lmox struct { + parentdir string + inner codejen.OneToOne[SchemaForGen] +} + +func (j *lmox) JennyName() string { + return "LatestMajorsOrXJenny" +} + +func (j *lmox) Generate(decl *DeclForGen) (codejen.Files, error) { + if decl.IsRaw() { + return nil, nil + } + comm := decl.Meta.Common() + sfg := SchemaForGen{ + Name: comm.Name, + IsGroup: comm.LineageIsGroup, + } + + do := func(sfg SchemaForGen, infix string) (codejen.Files, error) { + f, err := j.inner.Generate(sfg) + if err != nil { + return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Meta.Common().Name, err) + } + if f == nil || !f.Exists() { + return nil, nil + } + + f.RelativePath = filepath.Join(j.parentdir, comm.MachineName, infix, f.RelativePath) + f.From = append(f.From, j) + return codejen.Files{*f}, nil + } + + if comm.Maturity.Less(kindsys.MaturityStable) { + sfg.Schema = decl.Lineage().Latest() + return do(sfg, "x") + } + + var fl codejen.Files + for sch := decl.Lineage().First(); sch != nil; sch.Successor() { + sfg.Schema = sch.LatestInMajor() + files, err := do(sfg, fmt.Sprintf("v%v", sch.Version()[0])) + if err != nil { + return nil, err + } + fl = append(fl, files...) + } + if fl.Validate() != nil { + return nil, fl.Validate() + } + return fl, nil +} diff --git a/pkg/codegen/jenny_gotypes.go b/pkg/codegen/jenny_gotypes.go index 5d933ee6857..c662455eda4 100644 --- a/pkg/codegen/jenny_gotypes.go +++ b/pkg/codegen/jenny_gotypes.go @@ -1,98 +1,29 @@ package codegen import ( - "fmt" - "path/filepath" - "github.com/grafana/codejen" - "github.com/grafana/thema" "github.com/grafana/thema/encoding/gocode" "golang.org/x/tools/go/ast/astutil" ) -// GoTypesJenny creates a [OneToOne] that produces Go types for the latest -// Thema schema in a structured kind's lineage. -// -// At minimum, a gokindsdir must be provided. This should be the path to the parent -// directory of the directory in which the types should be generated, relative -// to the project root. For example, if the types for a kind named "foo" -// should live at pkg/kind/foo/foo_gen.go, relpath should be "pkg/kind". -// -// This generator is a no-op for raw kinds. -func GoTypesJenny(gokindsdir string, cfg *GoTypesGeneratorConfig) OneToOne { - if cfg == nil { - cfg = new(GoTypesGeneratorConfig) - } - if cfg.GenDirName == nil { - cfg.GenDirName = func(decl *DeclForGen) string { - return decl.Meta.Common().MachineName - } - } +// GoTypesJenny creates a [OneToOne] that produces Go types for the provided +// [thema.Schema]. +type GoTypesJenny struct{} - return &genGoTypes{ - gokindsdir: gokindsdir, - cfg: cfg, - } -} - -// GoTypesGeneratorConfig holds configuration options for [GoTypesJenny]. -type GoTypesGeneratorConfig struct { - // Apply is an optional AST manipulation func that, if provided, will be run - // against the generated Go file prior to running it through goimports. - Apply astutil.ApplyFunc - - // GenDirName returns the name of the parent directory in which the type file - // should be generated. If nil, the DeclForGen.Lineage().Name() will be used. - GenDirName func(*DeclForGen) string - - // Version of the schema to generate. If nil, latest is generated. - Version *thema.SyntacticVersion -} - -type genGoTypes struct { - gokindsdir string - cfg *GoTypesGeneratorConfig -} - -func (gen *genGoTypes) JennyName() string { +func (j GoTypesJenny) JennyName() string { return "GoTypesJenny" } -func (gen *genGoTypes) Generate(decl *DeclForGen) (*codejen.File, error) { - if decl.IsRaw() { - return nil, nil - } - - var sch thema.Schema - var err error - - lin := decl.Lineage() - if gen.cfg.Version == nil { - sch = lin.Latest() - } else { - sch, err = lin.Schema(*gen.cfg.Version) - if err != nil { - return nil, fmt.Errorf("error in configured version for %s generator: %w", *gen.cfg.Version, err) - } - } - - // always drop prefixes. - var appf []astutil.ApplyFunc - if gen.cfg.Apply != nil { - appf = append(appf, gen.cfg.Apply) - } - appf = append(appf, PrefixDropper(decl.Meta.Common().Name)) - - pdir := gen.cfg.GenDirName(decl) - fpath := filepath.Join(gen.gokindsdir, pdir, lin.Name()+"_types_gen.go") +func (j GoTypesJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { // TODO allow using name instead of machine name in thema generator - b, err := gocode.GenerateTypesOpenAPI(sch, &gocode.TypeConfigOpenAPI{ - PackageName: filepath.Base(pdir), - ApplyFuncs: appf, + b, err := gocode.GenerateTypesOpenAPI(sfg.Schema, &gocode.TypeConfigOpenAPI{ + // TODO will need to account for sanitizing e.g. dashes here at some point + PackageName: sfg.Schema.Lineage().Name(), + ApplyFuncs: []astutil.ApplyFunc{PrefixDropper(sfg.Name)}, }) if err != nil { return nil, err } - return codejen.NewFile(fpath, b, gen), nil + return codejen.NewFile(sfg.Schema.Lineage().Name()+"_types_gen.go", b, j), nil } diff --git a/pkg/codegen/jenny_tstypes.go b/pkg/codegen/jenny_tstypes.go index 110ec60ff1a..aa02b922eab 100644 --- a/pkg/codegen/jenny_tstypes.go +++ b/pkg/codegen/jenny_tstypes.go @@ -1,85 +1,32 @@ package codegen import ( - "fmt" - "path/filepath" - "github.com/grafana/codejen" - "github.com/grafana/thema" "github.com/grafana/thema/encoding/typescript" ) -// TSTypesJenny creates a [OneToOne] that produces TypeScript types and -// defaults for the latest Thema schema in a structured kind's lineage. +// TSTypesJenny is a [OneToOne] that produces TypeScript types and +// defaults for a Thema schema. // -// At minimum, a tskindsdir must be provided. This should be the path to the parent -// directory of the directory in which the types should be generated, relative -// to the project root. For example, if the types for a kind named "foo" -// should live at packages/grafana-schema/src/raw/foo, relpath should be "pkg/kind". -// -// This generator is a no-op for raw kinds. -func TSTypesJenny(tskindsdir string, cfg *TSTypesGeneratorConfig) OneToOne { - if cfg == nil { - cfg = new(TSTypesGeneratorConfig) - } - if cfg.GenDirName == nil { - cfg.GenDirName = func(decl *DeclForGen) string { - return decl.Meta.Common().MachineName - } - } +// Thema's generic TS jenny will be able to replace this one once +// https://github.com/grafana/thema/issues/89 is complete. +type TSTypesJenny struct{} - return &genTSTypes{ - tskindsdir: tskindsdir, - cfg: cfg, - } -} +var _ codejen.OneToOne[SchemaForGen] = &TSTypesJenny{} -// TSTypesGeneratorConfig holds configuration options for [TSTypesJenny]. -type TSTypesGeneratorConfig struct { - // GenDirName returns the name of the parent directory in which the type file - // should be generated. If nil, the DeclForGen.Lineage().Name() will be used. - GenDirName func(*DeclForGen) string - - // Version of the schema to generate. If nil, latest is generated. - Version *thema.SyntacticVersion -} - -type genTSTypes struct { - tskindsdir string - cfg *TSTypesGeneratorConfig -} - -func (gen *genTSTypes) JennyName() string { +func (j TSTypesJenny) JennyName() string { return "TSTypesJenny" } -func (gen *genTSTypes) Generate(decl *DeclForGen) (*codejen.File, error) { - if decl.IsRaw() { - return nil, nil - } - var sch thema.Schema - var err error - - lin := decl.Lineage() - if gen.cfg.Version == nil { - sch = lin.Latest() - } else { - sch, err = lin.Schema(*gen.cfg.Version) - if err != nil { - return nil, fmt.Errorf("error in configured version for %s generator: %w", *gen.cfg.Version, err) - } - } - +func (j TSTypesJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { // TODO allow using name instead of machine name in thema generator - f, err := typescript.GenerateTypes(sch, &typescript.TypeConfig{ - RootName: decl.Meta.Common().Name, - Group: decl.Meta.Common().LineageIsGroup, + f, err := typescript.GenerateTypes(sfg.Schema, &typescript.TypeConfig{ + RootName: sfg.Name, + Group: sfg.IsGroup, }) if err != nil { return nil, err } - return codejen.NewFile( - filepath.Join(gen.tskindsdir, gen.cfg.GenDirName(decl), lin.Name()+"_types.gen.ts"), - []byte(f.String()), - gen), nil + + return codejen.NewFile(sfg.Schema.Lineage().Name()+"_types.gen.ts", []byte(f.String()), j), nil } diff --git a/pkg/codegen/latest_jenny.go b/pkg/codegen/latest_jenny.go new file mode 100644 index 00000000000..5f24052a46a --- /dev/null +++ b/pkg/codegen/latest_jenny.go @@ -0,0 +1,56 @@ +package codegen + +import ( + "fmt" + "path/filepath" + + "github.com/grafana/codejen" +) + +// LatestJenny returns a jenny that runs another jenny for only the latest +// schema in a DeclForGen, and prefixes the resulting file with the provided +// parentdir (e.g. "pkg/kinds/") and with a directory based on the kind's +// machine name (e.g. "dashboard/"). +func LatestJenny(parentdir string, inner codejen.OneToOne[SchemaForGen]) OneToOne { + if inner == nil { + panic("inner jenny must not be nil") + } + + return &latestj{ + parentdir: parentdir, + inner: inner, + } +} + +type latestj struct { + parentdir string + inner codejen.OneToOne[SchemaForGen] +} + +func (j *latestj) JennyName() string { + return "LatestJenny" +} + +func (j *latestj) Generate(decl *DeclForGen) (*codejen.File, error) { + if decl.IsRaw() { + return nil, nil + } + comm := decl.Meta.Common() + sfg := SchemaForGen{ + Name: comm.Name, + Schema: decl.Lineage().Latest(), + IsGroup: comm.LineageIsGroup, + } + + f, err := j.inner.Generate(sfg) + if err != nil { + return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Meta.Common().Name, err) + } + if f == nil || !f.Exists() { + return nil, nil + } + + f.RelativePath = filepath.Join(j.parentdir, comm.MachineName, f.RelativePath) + f.From = append(f.From, j) + return f, nil +} diff --git a/pkg/codegen/tmpl.go b/pkg/codegen/tmpl.go index 5b4f8212f4c..a2639faa58b 100644 --- a/pkg/codegen/tmpl.go +++ b/pkg/codegen/tmpl.go @@ -5,6 +5,8 @@ import ( "embed" "text/template" "time" + + "github.com/grafana/codejen" ) // All the parsed templates in the tmpl subdirectory @@ -29,6 +31,11 @@ type ( LineageCUEPath string GenLicense bool } + tvars_gen_header struct { + MainGenerator string + Using []codejen.NamedJenny + From string + } tvars_kind_registry struct { // Header tvars_autogen_header NumRaw, NumStructured int diff --git a/pkg/codegen/tmpl/gen_header.tmpl b/pkg/codegen/tmpl/gen_header.tmpl new file mode 100644 index 00000000000..d32ee6f24b3 --- /dev/null +++ b/pkg/codegen/tmpl/gen_header.tmpl @@ -0,0 +1,11 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// {{ .MainGenerator }} +// Using jennies: +{{- range .Using }} +// {{ .JennyName }} +{{- end }} +// +// Run 'make gen-cue' from repository root to regenerate. + diff --git a/pkg/codegen/tmpl/kind_corestructured.tmpl b/pkg/codegen/tmpl/kind_corestructured.tmpl index 71fbac06074..a02a686b687 100644 --- a/pkg/codegen/tmpl/kind_corestructured.tmpl +++ b/pkg/codegen/tmpl/kind_corestructured.tmpl @@ -90,6 +90,7 @@ func (k *Kind) Maturity() kindsys.Maturity { } // TODO standard generated docs -func (k *Kind) Meta() kindsys.CoreStructuredMeta { - return k.decl.Meta +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { + d := k.decl + return &d } diff --git a/pkg/codegen/tmpl/kind_raw.tmpl b/pkg/codegen/tmpl/kind_raw.tmpl index 479915c124e..a38070c67e3 100644 --- a/pkg/codegen/tmpl/kind_raw.tmpl +++ b/pkg/codegen/tmpl/kind_raw.tmpl @@ -42,6 +42,7 @@ func (k *Kind) Maturity() kindsys.Maturity { } // TODO standard generated docs -func (k *Kind) Meta() kindsys.RawMeta { - return k.decl.Meta +func (k *Kind) Decl() *kindsys.Decl[kindsys.RawMeta] { + d := k.decl + return &d } diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index 5099164516f..a1e2671e245 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -26,7 +26,7 @@ type WebdavUploader struct { var netTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ - Timeout: 60 * time.Second, + Timeout: time.Minute, }).Dial, TLSHandshakeTimeout: 5 * time.Second, } diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 4b06f0ce65b..33e57d5e6a5 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -79,6 +79,12 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V querySeriesSet := vars[c.InputRefID] nilReducedCount := 0 firingCount := 0 + + if len(querySeriesSet.Values) == 0 { + // Append a NoData data frame so "has no value" still works + querySeriesSet.Values = append(querySeriesSet.Values, mathexp.NoData{}.New()) + } + for _, val := range querySeriesSet.Values { var reducedNum mathexp.Number var name string @@ -103,10 +109,6 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V // TODO handle error / no data signals thisCondNoDataFound := reducedNum.GetFloat64Value() == nil - if thisCondNoDataFound { - nilReducedCount++ - } - evalRes := c.Evaluator.Eval(reducedNum) if evalRes { @@ -119,6 +121,8 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V } matches = append(matches, match) firingCount++ + } else if thisCondNoDataFound { + nilReducedCount++ } } diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index e5d85f9dd78..fd656b2de4a 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -203,9 +203,8 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) - // This seems incorrect - v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { @@ -226,9 +225,8 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) - // This too seems incorrect, looks like we don't call the evaluator - v.SetMeta([]EvalMatch{{Metric: "NoData"}}) + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { @@ -251,9 +249,8 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) - // This seems incorrect - v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { @@ -473,6 +470,131 @@ func TestConditionsCmd(t *testing.T) { }) return newResults(v) }, + }, { + name: "two queries with two conditions using and operator and first is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(0)) + v.SetMeta([]EvalMatch{{Metric: "NoData"}, {Value: ptr.Float64(5)}}) + return newResults(v) + }, + }, { + // TODO: NoData behavior is different if the last condition is no data + name: "two queries with two conditions using and operator and last is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Metric: "NoData"}}) + return newResults(v) + }, + }, { + name: "two queries with two conditions using or operator and first is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Metric: "NoData"}, {Value: ptr.Float64(5)}}) + return newResults(v) + }, + }, { + name: "two queries with two conditions using or operator and last is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Metric: "NoData"}}) + return newResults(v) + }, }} for _, tt := range tests { diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index 82046d3eda0..d85b7db05e9 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -19,6 +19,7 @@ type DB interface { GetDBType() core.DbType GetSqlxSession() *session.SessionDB InTransaction(ctx context.Context, fn func(ctx context.Context) error) error + Quote(value string) string } type Session = sqlstore.DBSession diff --git a/pkg/infra/db/dbtest/dbtest.go b/pkg/infra/db/dbtest/dbtest.go index cdd9b7393c6..142626189e2 100644 --- a/pkg/infra/db/dbtest/dbtest.go +++ b/pkg/infra/db/dbtest/dbtest.go @@ -47,6 +47,10 @@ func (f *FakeDB) GetSqlxSession() *session.SessionDB { return nil } +func (f *FakeDB) Quote(value string) string { + return "" +} + // TODO: service-specific methods not yet split out ; to be removed func (f *FakeDB) UpdateTempUserWithEmailSent(ctx context.Context, cmd *models.UpdateTempUserWithEmailSentCommand) error { return f.ExpectedError diff --git a/pkg/kinds/dashboard/dashboard_kind_gen.go b/pkg/kinds/dashboard/dashboard_kind_gen.go index ee275dc8426..71c47986121 100644 --- a/pkg/kinds/dashboard/dashboard_kind_gen.go +++ b/pkg/kinds/dashboard/dashboard_kind_gen.go @@ -99,6 +99,7 @@ func (k *Kind) Maturity() kindsys.Maturity { } // TODO standard generated docs -func (k *Kind) Meta() kindsys.CoreStructuredMeta { - return k.decl.Meta +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { + d := k.decl + return &d } diff --git a/pkg/kinds/dashboard/dashboard_types_gen.go b/pkg/kinds/dashboard/dashboard_types_gen.go index e4d63c1a16b..c8faba1a7a5 100644 --- a/pkg/kinds/dashboard/dashboard_types_gen.go +++ b/pkg/kinds/dashboard/dashboard_types_gen.go @@ -4,6 +4,7 @@ // kinds/gen.go // Using jennies: // GoTypesJenny +// LatestJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/playlist/playlist_kind_gen.go b/pkg/kinds/playlist/playlist_kind_gen.go index eb82358c7bb..e67a4bc3dc2 100644 --- a/pkg/kinds/playlist/playlist_kind_gen.go +++ b/pkg/kinds/playlist/playlist_kind_gen.go @@ -99,6 +99,7 @@ func (k *Kind) Maturity() kindsys.Maturity { } // TODO standard generated docs -func (k *Kind) Meta() kindsys.CoreStructuredMeta { - return k.decl.Meta +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { + d := k.decl + return &d } diff --git a/pkg/kinds/playlist/playlist_types_gen.go b/pkg/kinds/playlist/playlist_types_gen.go index d45a61f3e2c..9b94aa11107 100644 --- a/pkg/kinds/playlist/playlist_types_gen.go +++ b/pkg/kinds/playlist/playlist_types_gen.go @@ -4,6 +4,7 @@ // kinds/gen.go // Using jennies: // GoTypesJenny +// LatestJenny // // Run 'make gen-cue' from repository root to regenerate. diff --git a/pkg/kinds/svg/svg_kind_gen.go b/pkg/kinds/svg/svg_kind_gen.go index 9722d91998b..b3f6a4f3150 100644 --- a/pkg/kinds/svg/svg_kind_gen.go +++ b/pkg/kinds/svg/svg_kind_gen.go @@ -49,6 +49,7 @@ func (k *Kind) Maturity() kindsys.Maturity { } // TODO standard generated docs -func (k *Kind) Meta() kindsys.RawMeta { - return k.decl.Meta +func (k *Kind) Decl() *kindsys.Decl[kindsys.RawMeta] { + d := k.decl + return &d } diff --git a/pkg/kindsys/kind.go b/pkg/kindsys/kind.go index ea02870ce26..9839954fb69 100644 --- a/pkg/kindsys/kind.go +++ b/pkg/kindsys/kind.go @@ -58,7 +58,7 @@ type Raw interface { Interface // TODO docs - Meta() RawMeta + Decl() *Decl[RawMeta] } type Structured interface { @@ -68,7 +68,7 @@ type Structured interface { Lineage() thema.Lineage // TODO docs - Meta() CoreStructuredMeta // TODO figure out how to reconcile this interface with CustomStructuredMeta + Decl() *Decl[CoreStructuredMeta] // TODO figure out how to reconcile this interface with CustomStructuredMeta } // type Composable interface { diff --git a/pkg/kindsys/kindmetas.go b/pkg/kindsys/kindmetas.go index 946c07fbe76..5470d8e26d6 100644 --- a/pkg/kindsys/kindmetas.go +++ b/pkg/kindsys/kindmetas.go @@ -2,7 +2,7 @@ package kindsys import "github.com/grafana/thema" -// CommonMeta contains the kind metadata common to all categories of kinds. +// CommonMeta contains the metadata common to all categories of kinds. type CommonMeta struct { Name string `json:"name"` PluralName string `json:"pluralName"` diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 7839d48bc67..382b4b4f288 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -50,16 +50,19 @@ func ProvideService(store db.DB, loginService login.Service, loginAttemptService // AuthenticateUser authenticates the user via username & password func (a *AuthenticatorService) AuthenticateUser(ctx context.Context, query *models.LoginUserQuery) error { - if err := validateLoginAttempts(ctx, query, a.loginAttemptService); err != nil { + ok, err := a.loginAttemptService.Validate(ctx, query.Username) + if err != nil { return err } + if !ok { + return ErrTooManyLoginAttempts + } if err := validatePasswordSet(query.Password); err != nil { return err } isGrafanaLoginEnabled := !query.Cfg.DisableLogin - var err error if isGrafanaLoginEnabled { err = loginUsingGrafanaDB(ctx, query, a.userService) @@ -84,7 +87,7 @@ func (a *AuthenticatorService) AuthenticateUser(ctx context.Context, query *mode } if errors.Is(err, ErrInvalidCredentials) || errors.Is(err, ldap.ErrInvalidCredentials) { - if err := saveInvalidLoginAttempt(ctx, query, a.loginAttemptService); err != nil { + if err := a.loginAttemptService.Add(ctx, query.Username, query.IpAddress); err != nil { loginLogger.Error("Failed to save invalid login attempt", "err", err) } diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go index dfa0a629db0..edc8f4ed3d8 100644 --- a/pkg/login/auth_test.go +++ b/pkg/login/auth_test.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/logintest" - "github.com/grafana/grafana/pkg/services/loginattempt" + "github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" @@ -18,16 +18,15 @@ import ( func TestAuthenticateUser(t *testing.T) { authScenario(t, "When a user authenticates without setting a password", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(nil, sc) mockLoginUsingLDAP(false, nil, sc) - loginQuery := models.LoginUserQuery{ + loginAttemptService := &loginattempttest.FakeLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), &models.LoginUserQuery{ Username: "user", Password: "", - } - a := AuthenticatorService{loginAttemptService: nil, loginService: &logintest.LoginServiceFake{}} - err := a.AuthenticateUser(context.Background(), &loginQuery) + }) require.EqualError(t, err, ErrPasswordEmpty.Error()) assert.False(t, sc.grafanaLoginWasCalled) @@ -36,164 +35,154 @@ func TestAuthenticateUser(t *testing.T) { }) authScenario(t, "When user authenticates with no auth provider enabled", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) sc.loginUserQuery.Cfg.DisableLogin = true - a := AuthenticatorService{loginAttemptService: nil, loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrNoAuthProvider.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.False(t, sc.grafanaLoginWasCalled) assert.False(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, "", sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When a user authenticates having too many login attempts", func(sc *authScenarioContext) { - mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc) mockLoginUsingGrafanaDB(nil, sc) mockLoginUsingLDAP(true, nil, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: false} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrTooManyLoginAttempts.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.False(t, sc.grafanaLoginWasCalled) assert.False(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Empty(t, sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When grafana user authenticate with valid credentials", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(nil, sc) mockLoginUsingLDAP(true, ErrInvalidCredentials, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.NoError(t, err) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.False(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, "grafana", sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) { customErr := errors.New("custom") - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(customErr, sc) mockLoginUsingLDAP(true, ErrInvalidCredentials, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, customErr.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.False(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, "grafana", sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(user.ErrUserNotFound, sc) mockLoginUsingLDAP(false, nil, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, user.ErrUserNotFound.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.True(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Empty(t, sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(user.ErrUserNotFound, sc) mockLoginUsingLDAP(true, ldap.ErrInvalidCredentials, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrInvalidCredentials.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.True(t, sc.ldapLoginWasCalled) - assert.True(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, login.LDAPAuthModule, sc.loginUserQuery.AuthModule) + assert.True(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(user.ErrUserNotFound, sc) mockLoginUsingLDAP(true, nil, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.NoError(t, err) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.True(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, login.LDAPAuthModule, sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) { customErr := errors.New("custom") - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(user.ErrUserNotFound, sc) mockLoginUsingLDAP(true, customErr, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, customErr.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.True(t, sc.ldapLoginWasCalled) - assert.False(t, sc.saveInvalidLoginAttemptWasCalled) assert.Equal(t, login.LDAPAuthModule, sc.loginUserQuery.AuthModule) + assert.False(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) authScenario(t, "When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) { - mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc) mockLoginUsingLDAP(true, ldap.ErrInvalidCredentials, sc) - mockSaveInvalidLoginAttempt(sc) - a := AuthenticatorService{loginService: &logintest.LoginServiceFake{}} + loginAttemptService := &loginattempttest.MockLoginAttemptService{ExpectedValid: true} + a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrInvalidCredentials.Error()) - assert.True(t, sc.loginAttemptValidationWasCalled) assert.True(t, sc.grafanaLoginWasCalled) assert.True(t, sc.ldapLoginWasCalled) - assert.True(t, sc.saveInvalidLoginAttemptWasCalled) + assert.True(t, loginAttemptService.AddCalled) + assert.True(t, loginAttemptService.ValidateCalled) }) } type authScenarioContext struct { - loginUserQuery *models.LoginUserQuery - grafanaLoginWasCalled bool - ldapLoginWasCalled bool - loginAttemptValidationWasCalled bool - saveInvalidLoginAttemptWasCalled bool + loginUserQuery *models.LoginUserQuery + grafanaLoginWasCalled bool + ldapLoginWasCalled bool } type authScenarioFunc func(sc *authScenarioContext) @@ -212,28 +201,12 @@ func mockLoginUsingLDAP(enabled bool, err error, sc *authScenarioContext) { } } -func mockLoginAttemptValidation(err error, sc *authScenarioContext) { - validateLoginAttempts = func(context.Context, *models.LoginUserQuery, loginattempt.Service) error { - sc.loginAttemptValidationWasCalled = true - return err - } -} - -func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { - saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery, _ loginattempt.Service) error { - sc.saveInvalidLoginAttemptWasCalled = true - return nil - } -} - func authScenario(t *testing.T, desc string, fn authScenarioFunc) { t.Helper() t.Run(desc, func(t *testing.T) { origLoginUsingGrafanaDB := loginUsingGrafanaDB origLoginUsingLDAP := loginUsingLDAP - origValidateLoginAttempts := validateLoginAttempts - origSaveInvalidLoginAttempt := saveInvalidLoginAttempt cfg := setting.Cfg{DisableLogin: false} sc := &authScenarioContext{ loginUserQuery: &models.LoginUserQuery{ @@ -247,8 +220,6 @@ func authScenario(t *testing.T, desc string, fn authScenarioFunc) { t.Cleanup(func() { loginUsingGrafanaDB = origLoginUsingGrafanaDB loginUsingLDAP = origLoginUsingLDAP - validateLoginAttempts = origValidateLoginAttempts - saveInvalidLoginAttempt = origSaveInvalidLoginAttempt }) fn(sc) diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go deleted file mode 100644 index 0c44067a5f2..00000000000 --- a/pkg/login/brute_force_login_protection.go +++ /dev/null @@ -1,48 +0,0 @@ -package login - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/loginattempt" -) - -var ( - maxInvalidLoginAttempts int64 = 5 - loginAttemptsWindow = time.Minute * 5 -) - -var validateLoginAttempts = func(ctx context.Context, query *models.LoginUserQuery, loginAttemptService loginattempt.Service) error { - if query.Cfg.DisableBruteForceLoginProtection { - return nil - } - - loginAttemptCountQuery := models.GetUserLoginAttemptCountQuery{ - Username: query.Username, - Since: time.Now().Add(-loginAttemptsWindow), - } - - if err := loginAttemptService.GetUserLoginAttemptCount(ctx, &loginAttemptCountQuery); err != nil { - return err - } - - if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts { - return ErrTooManyLoginAttempts - } - - return nil -} - -var saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery, loginAttemptService loginattempt.Service) error { - if query.Cfg.DisableBruteForceLoginProtection { - return nil - } - - loginAttemptCommand := models.CreateLoginAttemptCommand{ - Username: query.Username, - IpAddress: query.IpAddress, - } - - return loginAttemptService.CreateLoginAttempt(ctx, &loginAttemptCommand) -} diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go deleted file mode 100644 index 71d20fa02d0..00000000000 --- a/pkg/login/brute_force_login_protection_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package login - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" - "github.com/grafana/grafana/pkg/setting" -) - -func TestValidateLoginAttempts(t *testing.T) { - testCases := []struct { - name string - loginAttempts int64 - cfg *setting.Cfg - expected error - }{ - { - name: "When brute force protection enabled and user login attempt count is less than max", - loginAttempts: maxInvalidLoginAttempts - 1, - cfg: cfgWithBruteForceLoginProtectionEnabled(t), - expected: nil, - }, - { - name: "When brute force protection enabled and user login attempt count equals max", - loginAttempts: maxInvalidLoginAttempts, - cfg: cfgWithBruteForceLoginProtectionEnabled(t), - expected: ErrTooManyLoginAttempts, - }, - { - name: "When brute force protection enabled and user login attempt count is greater than max", - loginAttempts: maxInvalidLoginAttempts + 1, - cfg: cfgWithBruteForceLoginProtectionEnabled(t), - expected: ErrTooManyLoginAttempts, - }, - - { - name: "When brute force protection disabled and user login attempt count is less than max", - loginAttempts: maxInvalidLoginAttempts - 1, - cfg: cfgWithBruteForceLoginProtectionDisabled(t), - expected: nil, - }, - { - name: "When brute force protection disabled and user login attempt count equals max", - loginAttempts: maxInvalidLoginAttempts, - cfg: cfgWithBruteForceLoginProtectionDisabled(t), - expected: nil, - }, - { - name: "When brute force protection disabled and user login attempt count is greater than max", - loginAttempts: maxInvalidLoginAttempts + 1, - cfg: cfgWithBruteForceLoginProtectionDisabled(t), - expected: nil, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - store := mockstore.NewSQLStoreMock() - store.ExpectedLoginAttempts = tc.loginAttempts - - query := &models.LoginUserQuery{Username: "user", Cfg: tc.cfg} - - err := validateLoginAttempts(context.Background(), query, store) - require.Equal(t, tc.expected, err) - }) - } -} - -func TestSaveInvalidLoginAttempt(t *testing.T) { - t.Run("When brute force protection enabled", func(t *testing.T) { - store := mockstore.NewSQLStoreMock() - err := saveInvalidLoginAttempt(context.Background(), &models.LoginUserQuery{ - Username: "user", - Password: "pwd", - IpAddress: "192.168.1.1:56433", - Cfg: cfgWithBruteForceLoginProtectionEnabled(t), - }, store) - require.NoError(t, err) - - require.NotNil(t, store.LastLoginAttemptCommand) - assert.Equal(t, "user", store.LastLoginAttemptCommand.Username) - assert.Equal(t, "192.168.1.1:56433", store.LastLoginAttemptCommand.IpAddress) - }) - - t.Run("When brute force protection disabled", func(t *testing.T) { - store := mockstore.NewSQLStoreMock() - err := saveInvalidLoginAttempt(context.Background(), &models.LoginUserQuery{ - Username: "user", - Password: "pwd", - IpAddress: "192.168.1.1:56433", - Cfg: cfgWithBruteForceLoginProtectionDisabled(t), - }, store) - require.NoError(t, err) - - require.Nil(t, store.LastLoginAttemptCommand) - }) -} - -func cfgWithBruteForceLoginProtectionDisabled(t *testing.T) *setting.Cfg { - t.Helper() - cfg := setting.NewCfg() - cfg.DisableBruteForceLoginProtection = true - return cfg -} - -func cfgWithBruteForceLoginProtectionEnabled(t *testing.T) *setting.Cfg { - t.Helper() - cfg := setting.NewCfg() - require.False(t, cfg.DisableBruteForceLoginProtection) - return cfg -} diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index ea632e9457b..4a03dc12002 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func trueBoolPtr() *bool { @@ -54,7 +56,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -93,7 +95,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -143,7 +145,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Only other roles", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -171,7 +173,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -220,7 +222,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, { name: "Grafana Admin but setting is disabled", - fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false)}, + fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -242,7 +244,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { name: "Editor roles in claim and GrafanaAdminAssignment enabled", fields: fields{ SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)}, + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -263,7 +265,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Grafana Admin and Editor roles in claim", fields: fields{SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)}, + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -302,7 +304,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { fields: fields{ allowedGroups: []string{"foo", "bar"}, SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false), + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -324,7 +326,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when ClaimsNames and ClaimsSources is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ ID: "1", @@ -349,7 +351,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when forceUseGraphAPI is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), forceUseGraphAPI: true, }, claims: &azureClaims{ @@ -376,7 +378,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no match", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -392,7 +394,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no role claims returned", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -416,7 +418,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { } if tt.fields.SocialBase == nil { - s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false) + s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()) } key := []byte("secret") diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 69a47ccef22..d0cd1bc716d 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -14,6 +14,8 @@ import ( "strconv" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) type SocialGenericOAuth struct { @@ -504,3 +506,10 @@ func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, return logins, true } + +func (s *SocialGenericOAuth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { + opts = append(opts, oauth2.AccessTypeOffline) + } + return s.SocialBase.AuthCodeURL(state, opts...) +} diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index f610bd5843c..cdb400b15ea 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) const testGHUserTeamsJSON = `[ @@ -202,7 +204,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) { s := &SocialGithub{ SocialBase: newSocialBase("github", &oauth2.Config{}, - &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false), + &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false, *featuremgmt.WithFeatures()), allowedOrganizations: []string{}, apiUrl: server.URL + "/user", teamIds: []int{}, diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index 0c0a1d256dd..b499cc613be 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -6,6 +6,8 @@ import ( "net/http" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) type SocialGoogle struct { @@ -38,3 +40,10 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Login: data.Email, }, nil } + +func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { + opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + } + return s.SocialBase.AuthCodeURL(state, opts...) +} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 217f2606a19..fad3ed7095b 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -16,6 +16,7 @@ import ( "golang.org/x/text/language" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -58,7 +59,7 @@ type OAuthInfo struct { UsePKCE bool } -func ProvideService(cfg *setting.Cfg) *SocialService { +func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *SocialService { ss := SocialService{ cfg: cfg, oAuthProvider: make(map[string]*OAuthInfo), @@ -139,7 +140,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitHub. if name == "github" { ss.socialMap["github"] = &SocialGithub{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), @@ -149,7 +150,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitLab. if name == "gitlab" { ss.socialMap["gitlab"] = &SocialGitlab{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } @@ -158,7 +159,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Google. if name == "google" { ss.socialMap["google"] = &SocialGoogle{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), hostedDomain: info.HostedDomain, apiUrl: info.ApiUrl, } @@ -167,7 +168,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // AzureAD. if name == "azuread" { ss.socialMap["azuread"] = &SocialAzureAD{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false), } @@ -176,7 +177,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Okta if name == "okta" { ss.socialMap["okta"] = &SocialOkta{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } @@ -185,7 +186,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Generic - Uses the same scheme as GitHub. if name == "generic_oauth" { ss.socialMap["generic_oauth"] = &SocialGenericOAuth{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamsUrl: info.TeamsUrl, emailAttributeName: info.EmailAttributeName, @@ -214,8 +215,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { } ss.socialMap[grafanaCom] = &SocialGrafanaCom{ - SocialBase: newSocialBase(name, &config, info, - cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), url: cfg.GrafanaComURL, allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } @@ -261,6 +261,7 @@ type SocialBase struct { roleAttributeStrict bool autoAssignOrgRole string skipOrgRoleSync bool + features featuremgmt.FeatureManager } type Error struct { @@ -295,6 +296,7 @@ func newSocialBase(name string, info *OAuthInfo, autoAssignOrgRole string, skipOrgRoleSync bool, + features featuremgmt.FeatureManager, ) *SocialBase { logger := log.New("oauth." + name) @@ -308,6 +310,7 @@ func newSocialBase(name string, roleAttributePath: info.RoleAttributePath, roleAttributeStrict: info.RoleAttributeStrict, skipOrgRoleSync: skipOrgRoleSync, + features: features, } } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 7ab6274bc4c..b7e00465586 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -42,7 +43,7 @@ func notAuthorized(c *models.ReqContext) { c.Redirect(setting.AppSubUrl + "/login") } -func tokenRevoked(c *models.ReqContext, err *models.TokenRevokedError) { +func tokenRevoked(c *models.ReqContext, err *auth.TokenRevokedError) { if c.IsApiRequest() { c.JSON(401, map[string]interface{}{ "message": "Token revoked", @@ -117,7 +118,7 @@ func Auth(options *AuthOptions) web.Handler { requireLogin := !c.AllowAnonymous || forceLogin || options.ReqNoAnonynmous if !c.IsSignedIn && options.ReqSignedIn && requireLogin { - var revokedErr *models.TokenRevokedError + var revokedErr *auth.TokenRevokedError if errors.As(c.LookupTokenErr, &revokedErr) { tokenRevoked(c, revokedErr) return diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index fec44973196..8c029c2f597 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeytest" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -264,8 +265,8 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -288,14 +289,14 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: "", }, nil } - sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, + sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil @@ -371,8 +372,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Invalid/expired auth token in cookie", func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return nil, models.ErrUserTokenNotFound + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return nil, auth.ErrUserTokenNotFound } sc.fakeReq("GET", "/").exec() @@ -391,8 +392,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(11 * time.Second)} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -424,8 +425,8 @@ func TestMiddlewareContext(t *testing.T) { OAuthRefreshToken: "refresh_token"} sc.oauthTokenService.ExpectedErrors = map[string]error{"TryTokenRefresh": errors.New("error")} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -454,8 +455,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(-5 * time.Second), OAuthRefreshToken: "refreshtoken"} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -481,8 +482,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -819,14 +820,14 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.userService = usertest.NewUserServiceFake() sc.orgService = orgtest.NewOrgServiceFake() sc.apiKeyService = &apikeytest.Service{} - sc.oauthTokenService = &auth.FakeOAuthTokenService{} + sc.oauthTokenService = &authtest.FakeOAuthTokenService{} ctxHdlr := getContextHandler(t, cfg, sc.mockSQLStore, sc.loginService, sc.apiKeyService, sc.userService, sc.orgService, sc.oauthTokenService) sc.sqlStore = ctxHdlr.SQLStore sc.contextHandler = ctxHdlr sc.m.Use(ctxHdlr.Middleware) sc.m.Use(OrgRedirect(sc.cfg, sc.userService)) - sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*auth.FakeUserAuthTokenService) + sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*authtest.FakeUserAuthTokenService) sc.jwtAuthService = ctxHdlr.JWTAuthService.(*models.FakeJWTService) sc.remoteCacheService = ctxHdlr.RemoteCache @@ -856,7 +857,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.FakeDB, loginService *loginservice.LoginServiceMock, apiKeyService *apikeytest.Service, userService *usertest.FakeUserService, orgService *orgtest.FakeOrgService, - oauthTokenService *auth.FakeOAuthTokenService, + oauthTokenService *authtest.FakeOAuthTokenService, ) *contexthandler.ContextHandler { t.Helper() @@ -868,13 +869,13 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake } remoteCacheSvc := remotecache.NewFakeStore(t) - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore) authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}} - return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck), nil) + return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck)) } type fakeRenderService struct { diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 8d1460983df..810f04e56a1 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" ) @@ -48,8 +48,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { middlewareScenario(t, tc.desc, func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 1, UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil @@ -68,8 +68,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { sc.userService.ExpectedSetUsingOrgError = fmt.Errorf("") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 1, UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 446b7842933..156a626b10e 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -55,8 +55,8 @@ func TestMiddlewareQuota(t *testing.T) { setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 866eeaa490f..1a8fe8537c5 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -65,7 +65,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { sc.m.Use(AddDefaultResponseHeaders(cfg)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) - sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() + sc.userAuthTokenService = authtest.NewFakeUserAuthTokenService() sc.remoteCacheService = remotecache.NewFakeStore(t) contextHandler := getContextHandler(t, nil, nil, nil, nil, nil, nil, nil) diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index a091f9118fe..7e858cfd845 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey/apikeytest" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/login/loginservice" @@ -36,7 +36,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler web.Handler url string - userAuthTokenService *auth.FakeUserAuthTokenService + userAuthTokenService *authtest.FakeUserAuthTokenService jwtAuthService *models.FakeJWTService remoteCacheService *remotecache.RemoteCache cfg *setting.Cfg @@ -46,7 +46,7 @@ type scenarioContext struct { loginService *loginservice.LoginServiceMock apiKeyService *apikeytest.Service userService *usertest.FakeUserService - oauthTokenService *auth.FakeOAuthTokenService + oauthTokenService *authtest.FakeOAuthTokenService orgService *orgtest.FakeOrgService req *http.Request diff --git a/pkg/models/context.go b/pkg/models/context.go index dd07b7236b8..432dee40e99 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -15,7 +16,7 @@ import ( type ReqContext struct { *web.Context *user.SignedInUser - UserToken *UserToken + UserToken *usertoken.UserToken IsSignedIn bool IsRenderCall bool diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go deleted file mode 100644 index 6e0976bc506..00000000000 --- a/pkg/models/login_attempt.go +++ /dev/null @@ -1,36 +0,0 @@ -package models - -import ( - "time" -) - -type LoginAttempt struct { - Id int64 - Username string - IpAddress string - Created int64 -} - -// --------------------- -// COMMANDS - -type CreateLoginAttemptCommand struct { - Username string - IpAddress string - - Result LoginAttempt -} - -type DeleteOldLoginAttemptsCommand struct { - OlderThan time.Time - DeletedRows int64 -} - -// --------------------- -// QUERIES - -type GetUserLoginAttemptCountQuery struct { - Username string - Since time.Time - Result int64 -} diff --git a/pkg/models/usertoken/user_token.go b/pkg/models/usertoken/user_token.go new file mode 100644 index 00000000000..beb2ac1355f --- /dev/null +++ b/pkg/models/usertoken/user_token.go @@ -0,0 +1,26 @@ +package usertoken + +type TokenRevokedError struct { + UserID int64 + TokenID int64 + MaxConcurrentSessions int64 +} + +func (e *TokenRevokedError) Error() string { return "user token revoked" } + +// UserToken represents a user token +type UserToken struct { + Id int64 + UserId int64 + AuthToken string + PrevAuthToken string + UserAgent string + ClientIp string + AuthTokenSeen bool + SeenAt int64 + RotatedAt int64 + CreatedAt int64 + UpdatedAt int64 + RevokedAt int64 + UnhashedToken string +} diff --git a/pkg/plugins/manager/loader/initializer/initializer.go b/pkg/plugins/manager/loader/initializer/initializer.go index 4c5f6cdc50b..b484882c635 100644 --- a/pkg/plugins/manager/loader/initializer/initializer.go +++ b/pkg/plugins/manager/loader/initializer/initializer.go @@ -87,7 +87,7 @@ func (i *Initializer) awsEnvVars() []string { type pluginSettings map[string]string func (ps pluginSettings) asEnvVar(prefix string, hostEnv []string) []string { - var env []string + env := make([]string, 0, len(ps)) for k, v := range ps { key := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(k)) if value := os.Getenv(key); value != "" { diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index ec2cc099864..8bda9300e45 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -164,7 +164,6 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv "gettingstarted": {}, "graph": {}, "heatmap": {}, - "heatmap-old": {}, "histogram": {}, "icon": {}, "live": {}, diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue index b2b3df50e3e..a3a5b9e5ac6 100644 --- a/pkg/plugins/plugindef/plugindef.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -16,7 +16,7 @@ seqs: [ // grafana.com, then the plugin id has to follow the naming // conventions. id: string & strings.MinRunes(1) - id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|heatmap-old|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" + id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" // The set of all plugin types. This hidden field exists solely // so that the set can be string-interpolated into other fields. diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index eaaa092aa08..66181694739 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -7,10 +7,10 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/grpcserver" @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/live/pushhttp" "github.com/grafana/grafana/pkg/services/login/authinfoservice" + "github.com/grafana/grafana/pkg/services/loginattempt/loginattemptimpl" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/notifications" plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" @@ -38,7 +39,7 @@ import ( func ProvideBackgroundServiceRegistry( httpServer *api.HTTPServer, ng *ngalert.AlertNG, cleanup *cleanup.CleanUpService, live *live.GrafanaLive, pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, processManager *process.Manager, - rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, tracing tracing.Tracer, + rendering *rendering.RenderingService, tokenService auth.UserTokenBackgroundService, tracing tracing.Tracer, provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats, statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService, @@ -46,7 +47,7 @@ func ProvideBackgroundServiceRegistry( thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService, saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation, grpcServerProvider grpcserver.Provider, - secretMigrationProvider secretsMigrations.SecretMigrationProvider, + secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service, // Need to make sure these are initialized, is there a better place to put them? _ dashboardsnapshots.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, @@ -81,6 +82,7 @@ func ProvideBackgroundServiceRegistry( authInfoService, processManager, secretMigrationProvider, + loginAttemptService, ) } diff --git a/pkg/server/server.go b/pkg/server/server.go index aec724df979..4b80055b009 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -127,7 +127,7 @@ func (s *Server) init() error { } login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login, s.loginAttemptService, s.userService) - social.ProvideService(s.cfg) + social.ProvideService(s.cfg, s.HTTPServer.Features) if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil { return err diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 57338f5d46c..7c4dfeadb54 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -48,7 +48,6 @@ import ( "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/comments" @@ -85,6 +84,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfodatabase "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/loginattempt" "github.com/grafana/grafana/pkg/services/loginattempt/loginattemptimpl" "github.com/grafana/grafana/pkg/services/navtree/navtreeimpl" "github.com/grafana/grafana/pkg/services/ngalert" @@ -142,7 +142,6 @@ import ( "github.com/grafana/grafana/pkg/services/thumbs/dashboardthumbsimpl" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/services/userauth/userauthimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/cloudmonitoring" @@ -271,8 +270,6 @@ var wireBasicSet = wire.NewSet( influxdb.ProvideService, wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, - auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, @@ -367,7 +364,7 @@ var wireBasicSet = wire.NewSet( teamimpl.ProvideService, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, - userauthimpl.ProvideService, + wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), secretsMigrations.ProvideDataSourceMigrationService, secretsMigrations.ProvideMigrateToPluginService, secretsMigrations.ProvideMigrateFromPluginService, diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 5c366331875..6fae56eaaca 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" @@ -39,9 +40,9 @@ import ( ) var wireExtsBasicSet = wire.NewSet( - auth.ProvideUserAuthTokenService, - wire.Bind(new(models.UserTokenService), new(*auth.UserAuthTokenService)), - wire.Bind(new(models.UserTokenBackgroundService), new(*auth.UserAuthTokenService)), + authimpl.ProvideUserAuthTokenService, + wire.Bind(new(auth.UserTokenService), new(*authimpl.UserAuthTokenService)), + wire.Bind(new(auth.UserTokenBackgroundService), new(*authimpl.UserAuthTokenService)), licensing.ProvideService, wire.Bind(new(models.Licensing), new(*licensing.OSSLicensingService)), setting.ProvideProvider, diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index c0d3868e266..99b93d329dd 100644 --- a/pkg/services/accesscontrol/middleware.go +++ b/pkg/services/accesscontrol/middleware.go @@ -14,8 +14,8 @@ import ( "time" "github.com/grafana/grafana/pkg/middleware/cookies" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -41,7 +41,7 @@ func Middleware(ac AccessControl) func(web.Handler, Evaluator) web.Handler { } } - var revokedErr *models.TokenRevokedError + var revokedErr *usertoken.TokenRevokedError if errors.As(c.LookupTokenErr, &revokedErr) { unauthorized(c, revokedErr) return @@ -111,7 +111,7 @@ func unauthorized(c *models.ReqContext, err error) { "message": "Unauthorized", } - var revokedErr *models.TokenRevokedError + var revokedErr *usertoken.TokenRevokedError if errors.As(err, &revokedErr) { response["message"] = "Token revoked" response["error"] = map[string]interface{}{ diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index 16694fb7bf4..a852d79e682 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -486,8 +486,10 @@ func seedResourcePermissions(t *testing.T, store *store, sql *sqlstore.SQLStore, var org *models.Org for i := 0; i < numUsers; i++ { if org == nil { - addedOrg, err := sql.CreateOrgWithMember("test", int64(i)) + cmd := &models.CreateOrgCommand{Name: "test", UserId: int64(i)} + err := sql.CreateOrg(context.Background(), cmd) require.NoError(t, err) + addedOrg := cmd.Result org = &addedOrg } diff --git a/pkg/services/alerting/store_notification_test.go b/pkg/services/alerting/store_notification_test.go index a6d468f15e9..b2724ef946c 100644 --- a/pkg/services/alerting/store_notification_test.go +++ b/pkg/services/alerting/store_notification_test.go @@ -285,7 +285,7 @@ func TestIntegrationAlertNotificationSQLAccess(t *testing.T) { err := store.UpdateAlertNotification(context.Background(), newCmd) require.Nil(t, err) require.Equal(t, "NewName", newCmd.Result.Name) - require.Equal(t, 60*time.Second, newCmd.Result.Frequency) + require.Equal(t, time.Minute, newCmd.Result.Frequency) require.True(t, newCmd.Result.DisableResolveMessage) }) diff --git a/pkg/models/user_token.go b/pkg/services/auth/auth.go similarity index 73% rename from pkg/models/user_token.go rename to pkg/services/auth/auth.go index 6c92a40d86b..28d17307047 100644 --- a/pkg/models/user_token.go +++ b/pkg/services/auth/auth.go @@ -1,19 +1,28 @@ -package models +package auth import ( "context" "errors" "net" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) +const ( + QuotaTargetSrv quota.TargetSrv = "auth" + QuotaTarget quota.Target = "session" +) + // Typed errors var ( ErrUserTokenNotFound = errors.New("user token not found") ) +type TokenRevokedError = usertoken.TokenRevokedError + // CreateTokenErr represents a token creation error; used in Enterprise type CreateTokenErr struct { StatusCode int @@ -35,30 +44,7 @@ type TokenExpiredError struct { func (e *TokenExpiredError) Error() string { return "user token expired" } -type TokenRevokedError struct { - UserID int64 - TokenID int64 - MaxConcurrentSessions int64 -} - -func (e *TokenRevokedError) Error() string { return "user token revoked" } - -// UserToken represents a user token -type UserToken struct { - Id int64 - UserId int64 - AuthToken string - PrevAuthToken string - UserAgent string - ClientIp string - AuthTokenSeen bool - SeenAt int64 - RotatedAt int64 - CreatedAt int64 - UpdatedAt int64 - RevokedAt int64 - UnhashedToken string -} +type UserToken = usertoken.UserToken type RevokeAuthTokenCmd struct { AuthTokenId int64 `json:"authTokenId"` diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/authimpl/auth_token.go similarity index 75% rename from pkg/services/auth/auth_token.go rename to pkg/services/auth/authimpl/auth_token.go index f261e33bcd1..7bf1d8bf332 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/authimpl/auth_token.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -11,51 +11,24 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -const ServiceName = "UserAuthTokenService" +const urgentRotateTime = 1 * time.Minute var getTime = time.Now -const urgentRotateTime = 1 * time.Minute - -func ProvideUserAuthTokenService(sqlStore db.DB, serverLockService *serverlock.ServerLockService, - cfg *setting.Cfg) *UserAuthTokenService { +func ProvideUserAuthTokenService(sqlStore db.DB, cfg *setting.Cfg, serverLockService *serverlock.ServerLockService, quotaService quota.Service) (*UserAuthTokenService, error) { s := &UserAuthTokenService{ - SQLStore: sqlStore, - ServerLockService: serverLockService, - Cfg: cfg, + sqlStore: sqlStore, + serverLockService: serverLockService, + cfg: cfg, log: log.New("auth"), } - return s -} - -type UserAuthTokenService struct { - SQLStore db.DB - ServerLockService *serverlock.ServerLockService - Cfg *setting.Cfg - log log.Logger -} - -type ActiveTokenService interface { - ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) -} - -type ActiveAuthTokenService struct { - cfg *setting.Cfg - sqlStore db.DB -} - -func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaService quota.Service) (*ActiveAuthTokenService, error) { - s := &ActiveAuthTokenService{ - cfg: cfg, - sqlStore: sqlStore, - } defaultLimits, err := readQuotaConfig(cfg) if err != nil { @@ -63,9 +36,9 @@ func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaServic } if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: QuotaTargetSrv, + TargetSrv: auth.QuotaTargetSrv, DefaultLimits: defaultLimits, - Reporter: s.ActiveTokenCount, + Reporter: s.reportActiveTokenCount, }); err != nil { return s, err } @@ -73,30 +46,14 @@ func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaServic return s, nil } -func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { - var count int64 - var err error - err = a.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { - var model userAuthToken - count, err = dbSession.Where(`created_at > ? AND rotated_at > ? AND revoked_at = 0`, - getTime().Add(-a.cfg.LoginMaxLifetime).Unix(), - getTime().Add(-a.cfg.LoginMaxInactiveLifetime).Unix()). - Count(&model) - - return err - }) - - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u := "a.Map{} - u.Set(tag, count) - - return u, err +type UserAuthTokenService struct { + sqlStore db.DB + serverLockService *serverlock.ServerLockService + cfg *setting.Cfg + log log.Logger } -func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { +func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { token, err := util.RandomHex(16) if err != nil { return nil, err @@ -124,7 +81,7 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, AuthTokenSeen: false, } - err = s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { _, err = dbSession.Insert(&userAuthToken) return err }) @@ -138,18 +95,18 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, ctxLogger := s.log.FromContext(ctx) ctxLogger.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken) - var userToken models.UserToken + var userToken auth.UserToken err = userAuthToken.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*models.UserToken, error) { +func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { hashedToken := hashToken(unhashedToken) var model userAuthToken var exists bool var err error - err = s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { exists, err = dbSession.Where("(auth_token = ? OR prev_auth_token = ?)", hashedToken, hashedToken). @@ -162,14 +119,14 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st } if !exists { - return nil, models.ErrUserTokenNotFound + return nil, auth.ErrUserTokenNotFound } ctxLogger := s.log.FromContext(ctx) if model.RevokedAt > 0 { ctxLogger.Debug("user token has been revoked", "user ID", model.UserId, "token ID", model.Id) - return nil, &models.TokenRevokedError{ + return nil, &auth.TokenRevokedError{ UserID: model.UserId, TokenID: model.Id, } @@ -177,7 +134,7 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st if model.CreatedAt <= s.createdAfterParam() || model.RotatedAt <= s.rotatedAfterParam() { ctxLogger.Debug("user token has expired", "user ID", model.UserId, "token ID", model.Id) - return nil, &models.TokenExpiredError{ + return nil, &auth.TokenExpiredError{ UserID: model.UserId, TokenID: model.Id, } @@ -189,7 +146,7 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st expireBefore := getTime().Add(-urgentRotateTime).Unix() var affectedRows int64 - err = s.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { affectedRows, err = dbSession.Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", modelCopy.Id, modelCopy.PrevAuthToken, @@ -216,7 +173,7 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st modelCopy.SeenAt = getTime().Unix() var affectedRows int64 - err = s.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { affectedRows, err = dbSession.Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken). @@ -242,13 +199,13 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st model.UnhashedToken = unhashedToken - var userToken models.UserToken + var userToken auth.UserToken err = model.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models.UserToken, +func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { if token == nil { return false, nil @@ -264,7 +221,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models var needsRotation bool rotatedAt := time.Unix(model.RotatedAt, 0) if model.AuthTokenSeen { - needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute)) + needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute)) } else { needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime)) } @@ -300,9 +257,9 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)` var affected int64 - err = s.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { - res, err := dbSession.Exec(sql, userAgent, clientIPStr, s.SQLStore.GetDialect().BooleanStr(true), hashedToken, - s.SQLStore.GetDialect().BooleanStr(false), now.Unix(), model.Id, s.SQLStore.GetDialect().BooleanStr(true), + err = s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { + res, err := dbSession.Exec(sql, userAgent, clientIPStr, s.sqlStore.GetDialect().BooleanStr(true), hashedToken, + s.sqlStore.GetDialect().BooleanStr(false), now.Unix(), model.Id, s.sqlStore.GetDialect().BooleanStr(true), now.Add(-30*time.Second).Unix()) if err != nil { return err @@ -328,9 +285,9 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models return false, nil } -func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { +func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error { if token == nil { - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } model, err := userAuthTokenFromUserToken(token) @@ -342,12 +299,12 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us if soft { model.RevokedAt = getTime().Unix() - err = s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { rowsAffected, err = dbSession.ID(model.Id).Update(model) return err }) } else { - err = s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { rowsAffected, err = dbSession.Delete(model) return err }) @@ -361,7 +318,7 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us if rowsAffected == 0 { ctxLogger.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } ctxLogger.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "soft", soft) @@ -370,7 +327,7 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us } func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error { - return s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + return s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { sql := `DELETE from user_auth_token WHERE user_id = ?` res, err := dbSession.Exec(sql, userId) if err != nil { @@ -389,7 +346,7 @@ func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId i } func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error { - return s.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { + return s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { if len(userIds) == 0 { return nil } @@ -418,9 +375,9 @@ func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, use }) } -func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { - var result models.UserToken - err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { +func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { + var result auth.UserToken + err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { var token userAuthToken exists, err := dbSession.Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token) if err != nil { @@ -428,7 +385,7 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok } if !exists { - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } return token.toUserToken(&result) @@ -437,9 +394,9 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok return &result, err } -func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { - result := []*models.UserToken{} - err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { +func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { + result := []*auth.UserToken{} + err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { var tokens []*userAuthToken err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ? AND revoked_at = 0", userId, @@ -451,7 +408,7 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) } for _, token := range tokens { - var userToken models.UserToken + var userToken auth.UserToken if err := token.toUserToken(&userToken); err != nil { return err } @@ -464,9 +421,9 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) return result, err } -func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { - result := []*models.UserToken{} - err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { +func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { + result := []*auth.UserToken{} + err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { var tokens []*userAuthToken err := dbSession.Where("user_id = ? AND revoked_at > 0", userId).Find(&tokens) if err != nil { @@ -474,7 +431,7 @@ func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId } for _, token := range tokens { - var userToken models.UserToken + var userToken auth.UserToken if err := token.toUserToken(&userToken); err != nil { return err } @@ -487,12 +444,35 @@ func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId return result, err } +func (s *UserAuthTokenService) reportActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + var count int64 + var err error + err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { + var model userAuthToken + count, err = dbSession.Where(`created_at > ? AND rotated_at > ? AND revoked_at = 0`, + getTime().Add(-s.cfg.LoginMaxLifetime).Unix(), + getTime().Add(-s.cfg.LoginMaxInactiveLifetime).Unix()). + Count(&model) + + return err + }) + + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u := "a.Map{} + u.Set(tag, count) + + return u, err +} + func (s *UserAuthTokenService) createdAfterParam() int64 { - return getTime().Add(-s.Cfg.LoginMaxLifetime).Unix() + return getTime().Add(-s.cfg.LoginMaxLifetime).Unix() } func (s *UserAuthTokenService) rotatedAfterParam() int64 { - return getTime().Add(-s.Cfg.LoginMaxInactiveLifetime).Unix() + return getTime().Add(-s.cfg.LoginMaxInactiveLifetime).Unix() } func hashToken(token string) string { @@ -507,7 +487,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, nil } - globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + globalQuotaTag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) if err != nil { return limits, err } diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/authimpl/auth_token_test.go similarity index 94% rename from pkg/services/auth/auth_token_test.go rename to pkg/services/auth/authimpl/auth_token_test.go index 16886d7b439..25358aed7dd 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/authimpl/auth_token_test.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -8,12 +8,12 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/services/auth" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -29,7 +29,7 @@ func TestUserAuthToken(t *testing.T) { defer func() { getTime = time.Now }() t.Run("When creating token", func(t *testing.T) { - createToken := func() *models.UserToken { + createToken := func() *auth.UserToken { userToken, err := ctx.tokenService.CreateToken(context.Background(), user, net.ParseIP("192.168.10.11"), "some user agent") require.Nil(t, err) @@ -41,9 +41,9 @@ func TestUserAuthToken(t *testing.T) { userToken := createToken() t.Run("Can count active tokens", func(t *testing.T) { - m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) + m, err := ctx.tokenService.reportActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) require.NoError(t, err) count, ok := m.Get(tag) require.True(t, ok) @@ -65,7 +65,7 @@ func TestUserAuthToken(t *testing.T) { t.Run("When lookup hashed token should return user auth token not found error", func(t *testing.T) { userToken, err := ctx.tokenService.LookupToken(context.Background(), userToken.AuthToken) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) require.Nil(t, userToken) }) @@ -90,13 +90,13 @@ func TestUserAuthToken(t *testing.T) { t.Run("revoking nil token should return error", func(t *testing.T) { err := ctx.tokenService.RevokeToken(context.Background(), nil, false) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) }) t.Run("revoking non-existing token should return error", func(t *testing.T) { userToken.Id = 1000 err := ctx.tokenService.RevokeToken(context.Background(), userToken, false) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) }) ctx = createTestContext(t) @@ -209,13 +209,13 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken) - require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&models.TokenExpiredError{})) + require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&auth.TokenExpiredError{})) require.Nil(t, notGood) t.Run("should not find active token when expired", func(t *testing.T) { - m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) + m, err := ctx.tokenService.reportActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) require.NoError(t, err) count, ok := m.Get(tag) require.True(t, ok) @@ -247,7 +247,7 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken) - require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&models.TokenExpiredError{})) + require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&auth.TokenExpiredError{})) require.Nil(t, notGood) }) }) @@ -274,7 +274,7 @@ func TestUserAuthToken(t *testing.T) { model, err := ctx.getAuthTokenByID(userToken.Id) require.Nil(t, err) - var tok models.UserToken + var tok auth.UserToken err = model.toUserToken(&tok) require.Nil(t, err) @@ -471,7 +471,7 @@ func TestUserAuthToken(t *testing.T) { }) t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) { - ut := models.UserToken{ + ut := auth.UserToken{ Id: 1, UserId: 2, AuthToken: "a", @@ -524,7 +524,7 @@ func TestUserAuthToken(t *testing.T) { require.Nil(t, err) uatMap := uatJSON.MustMap() - var ut models.UserToken + var ut auth.UserToken err = uat.toUserToken(&ut) require.Nil(t, err) utBytes, err := json.Marshal(ut) @@ -550,27 +550,20 @@ func createTestContext(t *testing.T) *testContext { } tokenService := &UserAuthTokenService{ - SQLStore: sqlstore, - Cfg: cfg, + sqlStore: sqlstore, + cfg: cfg, log: log.New("test-logger"), } - activeTokenService := &ActiveAuthTokenService{ - cfg: cfg, - sqlStore: sqlstore, - } - return &testContext{ - sqlstore: sqlstore, - tokenService: tokenService, - activeTokenService: activeTokenService, + sqlstore: sqlstore, + tokenService: tokenService, } } type testContext struct { - sqlstore db.DB - tokenService *UserAuthTokenService - activeTokenService *ActiveAuthTokenService + sqlstore db.DB + tokenService *UserAuthTokenService } func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) { diff --git a/pkg/services/auth/model.go b/pkg/services/auth/authimpl/model.go similarity index 77% rename from pkg/services/auth/model.go rename to pkg/services/auth/authimpl/model.go index afc5b566c48..407927df572 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/authimpl/model.go @@ -1,10 +1,9 @@ -package auth +package authimpl import ( "fmt" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/auth" ) type userAuthToken struct { @@ -23,13 +22,13 @@ type userAuthToken struct { UnhashedToken string `xorm:"-"` } -func userAuthTokenFromUserToken(ut *models.UserToken) (*userAuthToken, error) { +func userAuthTokenFromUserToken(ut *auth.UserToken) (*userAuthToken, error) { var uat userAuthToken err := uat.fromUserToken(ut) return &uat, err } -func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { +func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) error { if uat == nil { return fmt.Errorf("needs pointer to userAuthToken struct") } @@ -51,7 +50,7 @@ func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { return nil } -func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { +func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error { if uat == nil { return fmt.Errorf("needs pointer to userAuthToken struct") } @@ -72,8 +71,3 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { return nil } - -const ( - QuotaTargetSrv quota.TargetSrv = "auth" - QuotaTarget quota.Target = "session" -) diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/authimpl/token_cleanup.go similarity index 85% rename from pkg/services/auth/token_cleanup.go rename to pkg/services/auth/authimpl/token_cleanup.go index a82f13630fe..7731184ce07 100644 --- a/pkg/services/auth/token_cleanup.go +++ b/pkg/services/auth/authimpl/token_cleanup.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -9,10 +9,10 @@ import ( func (s *UserAuthTokenService) Run(ctx context.Context) error { ticker := time.NewTicker(time.Hour) - maxInactiveLifetime := s.Cfg.LoginMaxInactiveLifetime - maxLifetime := s.Cfg.LoginMaxLifetime + maxInactiveLifetime := s.cfg.LoginMaxInactiveLifetime + maxLifetime := s.cfg.LoginMaxLifetime - err := s.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func(context.Context) { + err := s.serverLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func(context.Context) { if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { s.log.Error("An error occurred while deleting expired tokens", "err", err) } @@ -24,7 +24,7 @@ func (s *UserAuthTokenService) Run(ctx context.Context) error { for { select { case <-ticker.C: - err = s.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func(context.Context) { + err = s.serverLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func(context.Context) { if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { s.log.Error("An error occurred while deleting expired tokens", "err", err) } @@ -46,7 +46,7 @@ func (s *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxInact s.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore) var affected int64 - err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { + err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?` res, err := dbSession.Exec(sql, createdBefore.Unix(), rotatedBefore.Unix()) if err != nil { diff --git a/pkg/services/auth/token_cleanup_test.go b/pkg/services/auth/authimpl/token_cleanup_test.go similarity index 94% rename from pkg/services/auth/token_cleanup_test.go rename to pkg/services/auth/authimpl/token_cleanup_test.go index a39e0d7892b..b6623d99036 100644 --- a/pkg/services/auth/token_cleanup_test.go +++ b/pkg/services/auth/authimpl/token_cleanup_test.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -16,8 +16,8 @@ func TestUserAuthTokenCleanup(t *testing.T) { ctx := createTestContext(t) maxInactiveLifetime, _ := time.ParseDuration("168h") maxLifetime, _ := time.ParseDuration("720h") - ctx.tokenService.Cfg.LoginMaxInactiveLifetime = maxInactiveLifetime - ctx.tokenService.Cfg.LoginMaxLifetime = maxLifetime + ctx.tokenService.cfg.LoginMaxInactiveLifetime = maxInactiveLifetime + ctx.tokenService.cfg.LoginMaxLifetime = maxLifetime return ctx } diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/authtest/testing.go similarity index 77% rename from pkg/services/auth/testing.go rename to pkg/services/auth/authtest/testing.go index 63b08a9a639..d2bbd09b46b 100644 --- a/pkg/services/auth/testing.go +++ b/pkg/services/auth/authtest/testing.go @@ -1,4 +1,4 @@ -package auth +package authtest import ( "context" @@ -6,42 +6,43 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/user" "golang.org/x/oauth2" ) type FakeUserAuthTokenService struct { - CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) - TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) - LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error) - RevokeTokenProvider func(ctx context.Context, token *models.UserToken, soft bool) error + CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) + TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) + LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) + RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error ActiveAuthTokenCount func(ctx context.Context) (int64, error) - GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) - GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) - GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) + GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) + GetUserTokensProvider func(ctx context.Context, userId int64) ([]*auth.UserToken, error) + GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*auth.UserToken, error) BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error } func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { return &FakeUserAuthTokenService{ - CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { - return &models.UserToken{ + CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) { + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { return false, nil }, - LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - RevokeTokenProvider: func(ctx context.Context, token *models.UserToken, soft bool) error { + RevokeTokenProvider: func(ctx context.Context, token *auth.UserToken, soft bool) error { return nil }, RevokeAllUserTokensProvider: func(ctx context.Context, userId int64) error { @@ -53,10 +54,10 @@ func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { ActiveAuthTokenCount: func(ctx context.Context) (int64, error) { return 10, nil }, - GetUserTokenProvider: func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { + GetUserTokenProvider: func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return nil, nil }, - GetUserTokensProvider: func(ctx context.Context, userId int64) ([]*models.UserToken, error) { + GetUserTokensProvider: func(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return nil, nil }, } @@ -68,20 +69,20 @@ func (s *FakeUserAuthTokenService) Init() error { return nil } -func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { return s.CreateTokenProvider(context.Background(), user, clientIP, userAgent) } -func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return s.LookupTokenProvider(context.Background(), unhashedToken) } -func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *models.UserToken, clientIP net.IP, +func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { return s.TryRotateTokenProvider(context.Background(), token, clientIP, userAgent) } -func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { +func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error { return s.RevokeTokenProvider(context.Background(), token, soft) } @@ -93,15 +94,15 @@ func (s *FakeUserAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, return s.ActiveAuthTokenCount(context.Background()) } -func (s *FakeUserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return s.GetUserTokenProvider(context.Background(), userId, userTokenId) } -func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return s.GetUserTokensProvider(context.Background(), userId) } -func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return s.GetUserRevokedTokensProvider(context.Background(), userId) } diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index d8889d37912..a04dfdd125a 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" - "github.com/grafana/grafana/pkg/services/loginattempt" "github.com/grafana/grafana/pkg/services/ngalert/image" "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/shorturls" @@ -31,7 +30,7 @@ import ( func ProvideService(cfg *setting.Cfg, serverLockService *serverlock.ServerLockService, shortURLService shorturls.Service, sqlstore db.DB, queryHistoryService queryhistory.Service, dashboardVersionService dashver.Service, dashSnapSvc dashboardsnapshots.Service, deleteExpiredImageService *image.DeleteExpiredService, - loginAttemptService loginattempt.Service, tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner) *CleanUpService { + tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner) *CleanUpService { s := &CleanUpService{ Cfg: cfg, ServerLockService: serverLockService, @@ -42,7 +41,6 @@ func ProvideService(cfg *setting.Cfg, serverLockService *serverlock.ServerLockSe dashboardVersionService: dashboardVersionService, dashboardSnapshotService: dashSnapSvc, deleteExpiredImageService: deleteExpiredImageService, - loginAttemptService: loginAttemptService, tempUserService: tempUserService, tracer: tracer, annotationCleaner: annotationCleaner, @@ -61,7 +59,6 @@ type CleanUpService struct { dashboardVersionService dashver.Service dashboardSnapshotService dashboardsnapshots.Service deleteExpiredImageService *image.DeleteExpiredService - loginAttemptService loginattempt.Service tempUserService tempuser.Service annotationCleaner annotations.Cleaner } @@ -106,7 +103,6 @@ func (srv *CleanUpService) clean(ctx context.Context) { {"expire old user invites", srv.expireOldUserInvites}, {"delete stale short URLs", srv.deleteStaleShortURLs}, {"delete stale query history", srv.deleteStaleQueryHistory}, - {"delete old login attempts", srv.deleteOldLoginAttempts}, } logger := srv.log.FromContext(ctx) @@ -227,33 +223,6 @@ func (srv *CleanUpService) deleteExpiredImages(ctx context.Context) { } } -func (srv *CleanUpService) deleteOldLoginAttempts(ctx context.Context) { - logger := srv.log.FromContext(ctx) - err := srv.ServerLockService.LockAndExecute(ctx, "delete old login attempts", - time.Minute*10, func(context.Context) { - srv.deleteOldLoginAttemptsWithoutLock(ctx) - }) - if err != nil { - logger.Error("failed to lock and execute cleanup of old login attempts", "error", err) - } -} - -func (srv *CleanUpService) deleteOldLoginAttemptsWithoutLock(ctx context.Context) { - logger := srv.log.FromContext(ctx) - if srv.Cfg.DisableBruteForceLoginProtection { - return - } - - cmd := models.DeleteOldLoginAttemptsCommand{ - OlderThan: time.Now().Add(time.Minute * -10), - } - if err := srv.loginAttemptService.DeleteOldLoginAttempts(ctx, &cmd); err != nil { - logger.Error("Problem deleting expired login attempts", "error", err.Error()) - } else { - logger.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows) - } -} - func (srv *CleanUpService) expireOldUserInvites(ctx context.Context) { logger := srv.log.FromContext(ctx) maxInviteLifetime := srv.Cfg.UserInviteMaxLifetime diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 307ca2b51bc..3958f57e249 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" @@ -80,7 +80,7 @@ func getContextHandler(t *testing.T) *ContextHandler { cfg.AuthProxyHeaderProperty = "username" remoteCacheSvc, err := remotecache.ProvideService(cfg, sqlStore) require.NoError(t, err) - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() @@ -104,7 +104,7 @@ func getContextHandler(t *testing.T) *ContextHandler { return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, - &userService, orgService, nil, nil, nil) + &userService, orgService, nil, nil) } type FakeGetSignUserStore struct { diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index c8d67f165c0..f09015707d3 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -44,16 +44,11 @@ const ( const ServiceName = "ContextHandler" -func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtService models.JWTService, +func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtService models.JWTService, remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB, tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service, orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager, - // before 9.3.0 the quota service used to depend on on the ActiveTokenService - // since 9.3.0 after the quota refactoring ActiveTokenService depends on the quota - // therefore it's added to avoid cycle dependencies - // since it's used only by the middleware for enforcing quota limits. - activeTokenService auth.ActiveTokenService, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, @@ -77,7 +72,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS // ContextHandler is a middleware. type ContextHandler struct { Cfg *setting.Cfg - AuthTokenService models.UserTokenService + AuthTokenService auth.UserTokenService JWTAuthService models.JWTService RemoteCache *remotecache.RemoteCache RenderService rendering.Service @@ -449,20 +444,14 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org return false } - getTime := h.GetTime - if getTime == nil { - getTime = time.Now - } - if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { // Check whether the logged in User has a token (whether the User used an OAuth provider to login) oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult) if exists { - // Skip where the OAuthExpiry is default/zero/unset - if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) { + if h.hasAccessTokenExpired(oauthToken) { reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry)) - // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens + // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and invalidate the OAuth tokens if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil { if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) { reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err) @@ -474,7 +463,7 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org } err = h.AuthTokenService.RevokeToken(ctx, token, false) - if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { + if err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { reqContext.Logger.Error("failed to revoke auth token", "error", err) } return false @@ -506,8 +495,8 @@ func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *models. } } -func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext, authTokenService models.UserTokenService, - token *models.UserToken) web.BeforeFunc { +func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext, authTokenService auth.UserTokenService, + token *auth.UserToken) web.BeforeFunc { return func(w web.ResponseWriter) { // if response has already been written, skip. if w.Written() { @@ -732,3 +721,16 @@ func AuthHTTPHeaderListFromContext(c context.Context) *AuthHTTPHeaderList { } return nil } + +func (h *ContextHandler) hasAccessTokenExpired(token *models.UserAuth) bool { + if token.OAuthExpiry.IsZero() { + return false + } + + getTime := h.GetTime + if getTime == nil { + getTime = time.Now + } + + return token.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) +} diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 3e61b65d0ed..fa882f2fc81 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -7,14 +7,16 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDontRotateTokensOnCancelledRequests(t *testing.T) { @@ -25,15 +27,15 @@ func TestDontRotateTokensOnCancelledRequests(t *testing.T) { require.NoError(t, err) tryRotateCallCount := 0 - uts := &auth.FakeUserAuthTokenService{ - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, + uts := &authtest.FakeUserAuthTokenService{ + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { tryRotateCallCount++ return false, nil }, } - token := &models.UserToken{AuthToken: "oldtoken"} + token := &auth.UserToken{AuthToken: "oldtoken"} fn := ctxHdlr.rotateEndOfRequestFunc(reqContext, uts, token) cancel() @@ -48,8 +50,8 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { reqContext, rr, err := initTokenRotationScenario(context.Background(), t, ctxHdlr) require.NoError(t, err) - uts := &auth.FakeUserAuthTokenService{ - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, + uts := &authtest.FakeUserAuthTokenService{ + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { newToken, err := util.RandomHex(16) require.NoError(t, err) @@ -58,7 +60,7 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { }, } - token := &models.UserToken{AuthToken: "oldtoken"} + token := &auth.UserToken{AuthToken: "oldtoken"} ctxHdlr.rotateEndOfRequestFunc(reqContext, uts, token)(reqContext.Resp) diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index 0b345d21886..1178654d299 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -86,15 +86,20 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb // here we need to get FolderId from FolderUID if it present in the request, if both exist, FolderUID would overwrite FolderID if req.FolderUid != "" { folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ - OrgID: req.User.OrgID, - UID: &req.FolderUid, + OrgID: req.User.OrgID, + UID: &req.FolderUid, + SignedInUser: req.User, }) if err != nil { return nil, err } req.FolderId = folder.ID } else { - folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ID: &req.FolderId, OrgID: req.User.OrgID}) + folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ + ID: &req.FolderId, + OrgID: req.User.OrgID, + SignedInUser: req.User, + }) if err != nil { return nil, err } diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index b06cb9913fa..cd22275f6a0 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -542,7 +542,7 @@ func TestService_GetHttpTransport(t *testing.T) { func TestService_getTimeout(t *testing.T) { cfg := &setting.Cfg{} originalTimeout := sdkhttpclient.DefaultTimeoutOptions.Timeout - sdkhttpclient.DefaultTimeoutOptions.Timeout = 60 * time.Second + sdkhttpclient.DefaultTimeoutOptions.Timeout = time.Minute t.Cleanup(func() { sdkhttpclient.DefaultTimeoutOptions.Timeout = originalTimeout }) @@ -551,9 +551,9 @@ func TestService_getTimeout(t *testing.T) { jsonData *simplejson.Json expectedTimeout time.Duration }{ - {jsonData: simplejson.New(), expectedTimeout: 60 * time.Second}, - {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": nil}), expectedTimeout: 60 * time.Second}, - {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": 0}), expectedTimeout: 60 * time.Second}, + {jsonData: simplejson.New(), expectedTimeout: time.Minute}, + {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": nil}), expectedTimeout: time.Minute}, + {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": 0}), expectedTimeout: time.Minute}, {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": 1}), expectedTimeout: time.Second}, {jsonData: simplejson.NewFromAny(map[string]interface{}{"timeout": "2"}), expectedTimeout: 2 * time.Second}, } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 62352cfe6a3..b08a0bc4a34 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -248,11 +248,6 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, - { - Name: "useLegacyHeatmapPanel", - Description: "Continue to use the angular/flot based heatmap panel", - State: FeatureStateStable, - }, { Name: "disableSecretsCompatibility", Description: "Disable duplicated secret storage in legacy tables", @@ -270,8 +265,8 @@ var ( }, { Name: "internationalization", - Description: "Enables work-in-progress internationalization", - State: FeatureStateAlpha, + Description: "Enables internationalization", + State: FeatureStateStable, }, { Name: "topnav", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2441a1ab0fe..1c503d4867f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -179,10 +179,6 @@ const ( // Experimental framework to build interactive dashboards FlagScenes = "scenes" - // FlagUseLegacyHeatmapPanel - // Continue to use the angular/flot based heatmap panel - FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel" - // FlagDisableSecretsCompatibility // Disable duplicated secret storage in legacy tables FlagDisableSecretsCompatibility = "disableSecretsCompatibility" @@ -196,7 +192,7 @@ const ( FlagDataConnectionsConsole = "dataConnectionsConsole" // FlagInternationalization - // Enables work-in-progress internationalization + // Enables internationalization FlagInternationalization = "internationalization" // FlagTopnav diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 61aa379b549..f16aaa1abbc 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -34,7 +33,7 @@ type Service struct { dashboardService dashboards.DashboardService dashboardStore dashboards.Store searchService *search.SearchService - features *featuremgmt.FeatureManager + features featuremgmt.FeatureToggles permissions accesscontrol.FolderPermissionsService accessControl accesscontrol.AccessControl @@ -49,7 +48,7 @@ func ProvideService( dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, db db.DB, // DB for the (new) nested folder store - features *featuremgmt.FeatureManager, + features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, searchService *search.SearchService, ) folder.Service { @@ -93,30 +92,35 @@ func (s *Service) DBMigration(db db.DB) { } func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - user, err := appcontext.User(ctx) - if err != nil { - return nil, err + if cmd.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") } - if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagNestedFolders) { - if ok, err := s.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission( - dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(*cmd.UID), - )); !ok { + if s.features.IsEnabled(featuremgmt.FlagNestedFolders) { + f, err := s.store.Get(ctx, *cmd) + + if err != nil { + return nil, err + } + + g := guardian.New(ctx, f.ID, f.OrgID, cmd.SignedInUser) + if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) } return nil, dashboards.ErrFolderAccessDenied } - return s.store.Get(ctx, *cmd) + + return f, err } switch { case cmd.UID != nil: - return s.getFolderByUID(ctx, user, cmd.OrgID, *cmd.UID) + return s.getFolderByUID(ctx, cmd.SignedInUser, cmd.OrgID, *cmd.UID) case cmd.ID != nil: - return s.getFolderByID(ctx, user, *cmd.ID, cmd.OrgID) + return s.getFolderByID(ctx, cmd.SignedInUser, *cmd.ID, cmd.OrgID) case cmd.Title != nil: - return s.getFolderByTitle(ctx, user, cmd.OrgID, *cmd.Title) + return s.getFolderByTitle(ctx, cmd.SignedInUser, cmd.OrgID, *cmd.Title) default: return nil, folder.ErrBadRequest.Errorf("either on of UID, ID, Title fields must be present") } @@ -207,6 +211,8 @@ func (s *Service) getFolderByTitle(ctx context.Context, user *user.SignedInUser, } func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + logger := s.log.FromContext(ctx) + dashFolder := models.NewDashboardFolder(cmd.Title) dashFolder.OrgId = cmd.OrgID @@ -217,10 +223,10 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( dashFolder.SetUid(trimmedUID) - user, err := appcontext.User(ctx) - if err != nil { - return nil, err + if cmd.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") } + user := cmd.SignedInUser userID := user.UserID if userID == 0 { userID = -1 @@ -271,43 +277,40 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( } if permissionErr != nil { - s.log.Error("Could not make user admin", "folder", createdFolder.Title, "user", userID, "error", permissionErr) + logger.Error("Could not make user admin", "folder", createdFolder.Title, "user", userID, "error", permissionErr) } + var nestedFolder *folder.Folder if s.features.IsEnabled(featuremgmt.FlagNestedFolders) { - var description string - if dash.Data != nil { - description = dash.Data.Get("description").MustString() - } - - parentUID := folder.RootFolderUID - if cmd.ParentUID != "" { - parentUID = cmd.ParentUID - } - _, err := s.store.Create(ctx, folder.CreateFolderCommand{ + cmd := &folder.CreateFolderCommand{ // TODO: Today, if a UID isn't specified, the dashboard store // generates a new UID. The new folder store will need to do this as // well, but for now we take the UID from the newly created folder. UID: dash.Uid, OrgID: cmd.OrgID, Title: cmd.Title, - Description: description, - ParentUID: parentUID, - }) + Description: cmd.Description, + ParentUID: cmd.ParentUID, + } + nestedFolder, err = s.nestedFolderCreate(ctx, cmd) if err != nil { // We'll log the error and also roll back the previously-created // (legacy) folder. - s.log.Error("error saving folder to nested folder store", err) - err = s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.UID, OrgID: cmd.OrgID, ForceDeleteRules: true}) - if err != nil { - s.log.Error("error deleting folder after failed save to nested folder store", err) + logger.Error("error saving folder to nested folder store", "error", err) + // do not shallow create error if the legacy folder delete fails + deleteErr := s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.UID, OrgID: cmd.OrgID, ForceDeleteRules: true, SignedInUser: user}) + if deleteErr != nil { + logger.Error("error deleting folder after failed save to nested folder store", "error", err) } return folder.FromDashboard(dash), err } - // The folder UID is specified (or generated) during creation, so we'll - // stop here and return the created model.Folder. } - return folder.FromDashboard(dash), nil + + f := folder.FromDashboard(dash) + if nestedFolder != nil && nestedFolder.ParentUID != "" { + f.ParentUID = nestedFolder.ParentUID + } + return f, nil } func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*folder.Folder, error) { @@ -347,6 +350,8 @@ func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int } func (s *Service) legacyUpdate(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*folder.Folder, error) { + logger := s.log.FromContext(ctx) + query := models.GetDashboardQuery{OrgId: orgID, Uid: existingUid} _, err := s.dashboardStore.GetDashboard(ctx, &query) if err != nil { @@ -393,30 +398,31 @@ func (s *Service) legacyUpdate(ctx context.Context, user *user.SignedInUser, org UID: dash.Uid, OrgID: orgID, }); err != nil { - s.log.Error("failed to publish FolderTitleUpdated event", "folder", foldr.Title, "user", user.UserID, "error", err) + logger.Error("failed to publish FolderTitleUpdated event", "folder", foldr.Title, "user", user.UserID, "error", err) } } return foldr, nil } func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + logger := s.log.FromContext(ctx) + if cmd.SignedInUser == nil { + return folder.ErrBadRequest.Errorf("missing signed in user") + } + if s.features.IsEnabled(featuremgmt.FlagNestedFolders) { err := s.Delete(ctx, cmd) if err != nil { - s.log.Error("the delete folder on folder table failed with err: ", err.Error()) + logger.Error("the delete folder on folder table failed with err: ", "error", err) } } - user, err := appcontext.User(ctx) - if err != nil { - return err - } dashFolder, err := s.dashboardStore.GetFolderByUID(ctx, cmd.OrgID, cmd.UID) if err != nil { return err } - guard := guardian.New(ctx, dashFolder.ID, cmd.OrgID, user) + guard := guardian.New(ctx, dashFolder.ID, cmd.OrgID, cmd.SignedInUser) if canSave, err := guard.CanDelete(); err != nil || !canSave { if err != nil { return toFolderError(err) @@ -433,9 +439,14 @@ func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderComm } func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { + if cmd.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + foldr, err := s.Get(ctx, &folder.GetFolderQuery{ - UID: &cmd.UID, - OrgID: cmd.OrgID, + UID: &cmd.UID, + OrgID: cmd.OrgID, + SignedInUser: cmd.SignedInUser, }) if err != nil { return nil, err @@ -448,9 +459,14 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol } func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + if cmd.SignedInUser == nil { + return folder.ErrBadRequest.Errorf("missing signed in user") + } + _, err := s.Get(ctx, &folder.GetFolderQuery{ - UID: &cmd.UID, - OrgID: cmd.OrgID, + UID: &cmd.UID, + OrgID: cmd.OrgID, + SignedInUser: cmd.SignedInUser, }) if err != nil { return err @@ -489,6 +505,28 @@ func (s *Service) MakeUserAdmin(ctx context.Context, orgID int64, userID, folder return s.dashboardService.MakeUserAdmin(ctx, orgID, userID, folderID, setViewAndEditPermissions) } +func (s *Service) nestedFolderCreate(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + if cmd.ParentUID != "" { + if err := s.validateParent(ctx, cmd.OrgID, cmd.ParentUID); err != nil { + return nil, err + } + } + return s.store.Create(ctx, *cmd) +} + +func (s *Service) validateParent(ctx context.Context, orgID int64, parentUID string) error { + ancestors, err := s.store.GetParents(ctx, folder.GetParentsQuery{UID: parentUID, OrgID: orgID}) + if err != nil { + return err + } + + if len(ancestors) == folder.MaxNestedFolderDepth { + return folder.ErrMaximumDepthReached + } + + return nil +} + func toFolderError(err error) error { if errors.Is(err, dashboards.ErrDashboardTitleEmpty) { return dashboards.ErrFolderTitleEmpty diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 19ca8e8ddba..bfe18a5f2d0 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -3,15 +3,16 @@ package folderimpl import ( "context" "errors" + "fmt" "math/rand" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/xorcare/pointer" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" @@ -56,7 +57,6 @@ func TestIntegrationFolderService(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = false features := featuremgmt.WithFeatures() - cfg.IsFeatureToggleEnabled = features.IsEnabled folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() dashboardService := dashboardsvc.ProvideDashboardService(cfg, dashStore, nil, features, folderPermissions, dashboardPermissions, acmock.New()) @@ -88,28 +88,40 @@ func TestIntegrationFolderService(t *testing.T) { dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(f, nil) t.Run("When get folder by id should return access denied error", func(t *testing.T) { - _, err := service.getFolderByID(context.Background(), usr, folderId, orgID) + _, err := service.Get(context.Background(), &folder.GetFolderQuery{ + ID: &folderId, + OrgID: orgID, + SignedInUser: usr, + }) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) t.Run("When get folder by id, with id = 0 should return default folder", func(t *testing.T) { - foldr, err := service.getFolderByID(context.Background(), usr, 0, orgID) + foldr, err := service.Get(context.Background(), &folder.GetFolderQuery{ + ID: pointer.Int64(0), + OrgID: orgID, + SignedInUser: usr, + }) require.NoError(t, err) require.Equal(t, foldr, &folder.Folder{ID: 0, Title: "General"}) }) t.Run("When get folder by uid should return access denied error", func(t *testing.T) { - _, err := service.getFolderByUID(context.Background(), usr, orgID, folderUID) + _, err := service.Get(context.Background(), &folder.GetFolderQuery{ + UID: &folderUID, + OrgID: orgID, + SignedInUser: usr, + }) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) t.Run("When creating folder should return access denied error", func(t *testing.T) { dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil).Times(2) - ctx := appcontext.WithUser(context.Background(), usr) - _, err := service.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: f.Title, - UID: folderUID, + _, err := service.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: f.Title, + UID: folderUID, + SignedInUser: usr, }) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) @@ -128,19 +140,17 @@ func TestIntegrationFolderService(t *testing.T) { }) t.Run("When deleting folder by uid should return access denied error", func(t *testing.T) { - ctx := context.Background() - ctx = appcontext.WithUser(ctx, usr) - newFolder := models.NewFolder("Folder") newFolder.Uid = folderUID dashStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(newFolder, nil) dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(newFolder, nil) - err := service.DeleteFolder(ctx, &folder.DeleteFolderCommand{ + err := service.DeleteFolder(context.Background(), &folder.DeleteFolderCommand{ UID: folderUID, OrgID: orgID, ForceDeleteRules: false, + SignedInUser: usr, }) require.Error(t, err) require.Equal(t, err, dashboards.ErrFolderAccessDenied) @@ -164,11 +174,11 @@ func TestIntegrationFolderService(t *testing.T) { dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dash, nil).Once() dashStore.On("GetFolderByID", mock.Anything, orgID, dash.Id).Return(f, nil) - ctx := appcontext.WithUser(context.Background(), usr) - actualFolder, err := service.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: dash.Title, - UID: "someuid", + actualFolder, err := service.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: dash.Title, + UID: "someuid", + SignedInUser: usr, }) require.NoError(t, err) require.Equal(t, f, actualFolder) @@ -178,11 +188,11 @@ func TestIntegrationFolderService(t *testing.T) { dash := models.NewDashboardFolder("Test-Folder") dash.Id = rand.Int63() - ctx := appcontext.WithUser(context.Background(), usr) - _, err := service.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: dash.Title, - UID: "general", + _, err := service.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: dash.Title, + UID: "general", + SignedInUser: usr, }) require.ErrorIs(t, err, dashboards.ErrFolderInvalidUID) }) @@ -219,12 +229,11 @@ func TestIntegrationFolderService(t *testing.T) { }).Return(nil).Once() expectedForceDeleteRules := rand.Int63()%2 == 0 - ctx := context.Background() - ctx = appcontext.WithUser(ctx, usr) - err := service.DeleteFolder(ctx, &folder.DeleteFolderCommand{ + err := service.DeleteFolder(context.Background(), &folder.DeleteFolderCommand{ UID: f.UID, OrgID: orgID, ForceDeleteRules: expectedForceDeleteRules, + SignedInUser: usr, }) require.NoError(t, err) require.NotNil(t, actualCmd) @@ -315,28 +324,20 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) cfg := setting.NewCfg() cfg.RBACEnabled = false - nestedFoldersEnabled := true - features := featuremgmt.WithFeatures() - cfg.IsFeatureToggleEnabled = func(key string) bool { - if key == featuremgmt.FlagNestedFolders { - return nestedFoldersEnabled - } - return false - } - cfg.IsFeatureToggleEnabled = features.IsEnabled folderService := &Service{ cfg: cfg, store: folderStore, dashboardStore: &dashStore, dashboardService: &dashboardsvc, - features: features, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + log: log.New("test-folder-service"), } t.Run("create folder", func(t *testing.T) { - folderStore.ExpectedFolder = &folder.Folder{} - ctx := appcontext.WithUser(context.Background(), usr) - res, err := folderService.Create(ctx, &folder.CreateFolderCommand{}) + folderStore.ExpectedFolder = &folder.Folder{ParentUID: util.GenerateShortUID()} + res, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{SignedInUser: usr}) require.NoError(t, err) require.NotNil(t, res.UID) + require.NotEmpty(t, res.ParentUID) }) t.Run("get parents folder", func(t *testing.T) { @@ -371,22 +372,19 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { func TestNestedFolderService(t *testing.T) { t.Run("with feature flag unset", func(t *testing.T) { - ctx := appcontext.WithUser(context.Background(), usr) store := &FakeStore{} dashStore := dashboards.FakeDashboardStore{} dashboardsvc := dashboards.FakeDashboardService{} // nothing enabled yet cfg := setting.NewCfg() cfg.RBACEnabled = false - features := featuremgmt.WithFeatures() - cfg.IsFeatureToggleEnabled = features.IsEnabled foldersvc := &Service{ cfg: cfg, log: log.New("test-folder-service"), dashboardService: &dashboardsvc, dashboardStore: &dashStore, store: store, - features: features, + features: featuremgmt.WithFeatures(), } t.Run("When create folder, no create in folder table done", func(t *testing.T) { @@ -397,11 +395,11 @@ func TestNestedFolderService(t *testing.T) { dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) - ctx = appcontext.WithUser(ctx, usr) - _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "myFolder", - UID: "myFolder", + _, err := foldersvc.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + SignedInUser: usr, }) require.NoError(t, err) // CreateFolder should not call the folder store create if the feature toggle is not enabled. @@ -418,7 +416,7 @@ func TestNestedFolderService(t *testing.T) { g := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - err := foldersvc.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: "myFolder", OrgID: orgID}) + err := foldersvc.DeleteFolder(context.Background(), &folder.DeleteFolderCommand{UID: "myFolder", OrgID: orgID, SignedInUser: usr}) require.NoError(t, err) require.NotNil(t, actualCmd) @@ -430,22 +428,19 @@ func TestNestedFolderService(t *testing.T) { }) t.Run("with nested folder feature flag on", func(t *testing.T) { - ctx := appcontext.WithUser(context.Background(), usr) store := &FakeStore{} dashStore := &dashboards.FakeDashboardStore{} dashboardsvc := &dashboards.FakeDashboardService{} // nothing enabled yet cfg := setting.NewCfg() cfg.RBACEnabled = false - features := featuremgmt.WithFeatures("nestedFolders") - cfg.IsFeatureToggleEnabled = features.IsEnabled foldersvc := &Service{ cfg: cfg, log: log.New("test-folder-service"), dashboardService: dashboardsvc, dashboardStore: dashStore, store: store, - features: features, + features: featuremgmt.WithFeatures("nestedFolders"), accessControl: actest.FakeAccessControl{ ExpectedEvaluate: true, }, @@ -458,11 +453,11 @@ func TestNestedFolderService(t *testing.T) { mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) - ctx = appcontext.WithUser(ctx, usr) - _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "myFolder", - UID: "myFolder", + _, err := foldersvc.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + SignedInUser: usr, }) require.NoError(t, err) // CreateFolder should also call the folder store's create method. @@ -486,11 +481,11 @@ func TestNestedFolderService(t *testing.T) { store.ExpectedError = errors.New("FAILED") // the service return success as long as the legacy create succeeds - ctx = appcontext.WithUser(ctx, usr) - _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ - OrgID: orgID, - Title: "myFolder", - UID: "myFolder", + _, err := foldersvc.Create(context.Background(), &folder.CreateFolderCommand{ + OrgID: orgID, + Title: "myFolder", + UID: "myFolder", + SignedInUser: usr, }) require.Error(t, err, "FAILED") @@ -503,9 +498,16 @@ func TestNestedFolderService(t *testing.T) { }) t.Run("move, no error", func(t *testing.T) { + // This test creates and deletes the dashboard, so needs some extra setup. + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + store.ExpectedError = nil store.ExpectedFolder = &folder.Folder{UID: "myFolder", ParentUID: "newFolder"} - f, err := foldersvc.Move(ctx, &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder", OrgID: orgID}) + f, err := foldersvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder", OrgID: orgID, SignedInUser: usr}) require.NoError(t, err) require.NotNil(t, f) }) @@ -518,9 +520,9 @@ func TestNestedFolderService(t *testing.T) { dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&models.Folder{}, nil) g := guardian.New - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) - err := foldersvc.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: "myFolder", OrgID: orgID}) + err := foldersvc.DeleteFolder(context.Background(), &folder.DeleteFolderCommand{UID: "myFolder", OrgID: orgID, SignedInUser: usr}) require.NoError(t, err) require.NotNil(t, actualCmd) @@ -529,5 +531,43 @@ func TestNestedFolderService(t *testing.T) { }) require.True(t, store.DeleteCalled) }) + + t.Run("create returns error if maximum depth reached", func(t *testing.T) { + // This test creates and deletes the dashboard, so needs some extra setup. + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + + // dashboard store & service commands that should be called. + dashboardsvc.On("BuildSaveDashboardCommand", + mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), + mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) + dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&folder.Folder{}, nil) + var actualCmd *models.DeleteDashboardCommand + dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + actualCmd = args.Get(1).(*models.DeleteDashboardCommand) + }).Return(nil).Once() + + parents := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth) + for i := 0; i < folder.MaxNestedFolderDepth; i++ { + parents = append(parents, &folder.Folder{UID: fmt.Sprintf("folder%d", i)}) + } + store.ExpectedFolders = parents + store.ExpectedError = nil + + _, err := foldersvc.Create(context.Background(), &folder.CreateFolderCommand{ + Title: "folder", + OrgID: orgID, + ParentUID: parents[len(parents)-1].UID, + UID: util.GenerateShortUID(), + SignedInUser: usr, + }) + assert.ErrorIs(t, err, folder.ErrMaximumDepthReached) + require.NotNil(t, actualCmd) + }) }) } diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 2b496eff878..b8dbfbe9351 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -10,6 +10,7 @@ import ( "github.com/go-sql-driver/mysql" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/setting" @@ -37,13 +38,9 @@ func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) var foldr *folder.Folder /* - user, err := appcontext.User(ctx) - if err != nil { - return nil, err - } version := 1 - updatedBy := user.UserID - createdBy := user.UserID + updatedBy := cmd.SignedInUser.UserID + createdBy := cmd.SignedInUser.UserID */ err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sqlOrArgs []interface{} @@ -173,6 +170,7 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F } return nil }) + foldr.Url = models.GetFolderUrl(foldr.UID, models.SlugifyTitle(foldr.Title)) return foldr, err } diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 6a6dcb85182..4824d934d38 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -133,28 +133,6 @@ func TestIntegrationCreate(t *testing.T) { assert.Equal(t, "folder desc", ff.Description) assert.Equal(t, parentUID, ff.ParentUID) }) - - /* - t.Run("creating a nested folder with the maximum nested folder depth should fail", func(t *testing.T) { - ancestorUIDs := createSubTree(t, folderStore, orgID, accesscontrol.GeneralFolderUID, folder.MaxNestedFolderDepth, "") - - t.Cleanup(func() { - for _, uid := range ancestorUIDs[1:] { - err := folderStore.Delete(context.Background(), uid, orgID) - require.NoError(t, err) - } - }) - - title := fmt.Sprintf("folder-%d", len(ancestorUIDs)) - _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: "folder1", - OrgID: orgID, - ParentUID: ancestorUIDs[len(ancestorUIDs)-1], - UID: util.GenerateShortUID(), - }) - assert.Error(t, err) - }) - */ } func TestIntegrationDelete(t *testing.T) { diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index ea736371ef5..87b592c5d97 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -66,6 +67,8 @@ type CreateFolderCommand struct { Title string `json:"title"` Description string `json:"description"` ParentUID string `json:"parent_uid"` + + SignedInUser *user.SignedInUser `json:"-"` } // UpdateFolderCommand captures the information required by the folder service @@ -75,6 +78,8 @@ type UpdateFolderCommand struct { NewUID *string `json:"uid" xorm:"uid"` NewTitle *string `json:"title"` NewDescription *string `json:"description"` + + SignedInUser *user.SignedInUser `json:"-"` } // MoveFolderCommand captures the information required by the folder service @@ -83,6 +88,8 @@ type MoveFolderCommand struct { UID string `json:"uid"` NewParentUID string `json:"new_parent_uid"` OrgID int64 `json:"-"` + + SignedInUser *user.SignedInUser `json:"-"` } // DeleteFolderCommand captures the information required by the folder service @@ -91,6 +98,8 @@ type DeleteFolderCommand struct { UID string `json:"uid" xorm:"uid"` OrgID int64 `json:"orgId" xorm:"org_id"` ForceDeleteRules bool `json:"forceDeleteRules"` + + SignedInUser *user.SignedInUser `json:"-"` } // GetFolderQuery is used for all folder Get requests. Only one of UID, ID, or @@ -102,6 +111,8 @@ type GetFolderQuery struct { ID *int64 Title *string OrgID int64 + + SignedInUser *user.SignedInUser `json:"-"` } // GetParentsQuery captures the information required by the folder service to diff --git a/pkg/services/ldap/helpers.go b/pkg/services/ldap/helpers.go index 851725f2d19..384f2adb7c4 100644 --- a/pkg/services/ldap/helpers.go +++ b/pkg/services/ldap/helpers.go @@ -34,7 +34,7 @@ func getAttribute(name string, entry *ldap.Entry) string { } for _, attr := range entry.Attributes { - if attr.Name == name { + if strings.EqualFold(attr.Name, name) { if len(attr.Values) > 0 { return attr.Values[0] } @@ -49,7 +49,7 @@ func getArrayAttribute(name string, entry *ldap.Entry) []string { } for _, attr := range entry.Attributes { - if attr.Name == name && len(attr.Values) > 0 { + if strings.EqualFold(attr.Name, name) && len(attr.Values) > 0 { return attr.Values } } diff --git a/pkg/services/ldap/ldap_helpers_test.go b/pkg/services/ldap/ldap_helpers_test.go index 887a2a0a5df..bf45cb0b0a3 100644 --- a/pkg/services/ldap/ldap_helpers_test.go +++ b/pkg/services/ldap/ldap_helpers_test.go @@ -83,6 +83,20 @@ func TestGetAttribute(t *testing.T) { assert.Equal(t, value, result) }) + t.Run("letter case mismatch", func(t *testing.T) { + value := "roelgerrits" + entry := &ldap.Entry{ + Attributes: []*ldap.EntryAttribute{ + { + Name: "sAMAccountName", Values: []string{value}, + }, + }, + } + + result := getAttribute("samaccountname", entry) + assert.Equal(t, value, result) + }) + t.Run("no result", func(t *testing.T) { value := []string{"roelgerrits"} entry := &ldap.Entry{ @@ -124,6 +138,21 @@ func TestGetArrayAttribute(t *testing.T) { assert.EqualValues(t, value, result) }) + t.Run("letter case mismatch", func(t *testing.T) { + value := []string{"CN=Administrators,CN=Builtin,DC=grafana,DC=org"} + entry := &ldap.Entry{ + Attributes: []*ldap.EntryAttribute{ + { + Name: "memberOf", Values: value, + }, + }, + } + + result := getArrayAttribute("memberof", entry) + + assert.EqualValues(t, value, result) + }) + t.Run("no result", func(t *testing.T) { value := []string{"roelgerrits"} entry := &ldap.Entry{ diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index d499390b593..d6afb11f1a2 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -48,7 +48,7 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res if *cmd.FolderUID == "" { cmd.FolderID = 0 } else { - folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID}) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID, SignedInUser: c.SignedInUser}) if err != nil || folder == nil { return response.Error(http.StatusBadRequest, "failed to get folder", err) } @@ -62,7 +62,7 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res } if element.FolderID != 0 { - folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID}) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID, SignedInUser: c.SignedInUser}) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get folder", err) } @@ -176,7 +176,7 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp if *cmd.FolderUID == "" { cmd.FolderID = 0 } else { - folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID}) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID, SignedInUser: c.SignedInUser}) if err != nil || folder == nil { return response.Error(http.StatusBadRequest, "failed to get folder", err) } @@ -190,7 +190,7 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp } if element.FolderID != 0 { - folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID}) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID, SignedInUser: c.SignedInUser}) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get folder", err) } diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index 958ded662d6..e216507e84a 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -40,7 +40,7 @@ func (l *LibraryElementService) requireEditPermissionsOnFolder(ctx context.Conte if isGeneralFolder(folderID) && user.HasRole(org.RoleViewer) { return dashboards.ErrFolderAccessDenied } - folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID}) + folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID, SignedInUser: user}) if err != nil { return err } @@ -63,7 +63,7 @@ func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Conte return nil } - folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID}) + folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID, SignedInUser: user}) if err != nil { return err } diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 0a273577558..49e02f71790 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -321,7 +321,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S t.Logf("Creating folder with title and UID %q", title) ctx := appcontext.WithUser(context.Background(), &user) folder, err := s.Create(ctx, &folder.CreateFolderCommand{ - OrgID: user.OrgID, Title: title, UID: title, + OrgID: user.OrgID, Title: title, UID: title, SignedInUser: &user, }) require.NoError(t, err) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 202f09eae26..356ea61e045 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -728,7 +728,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. t.Logf("Creating folder with title and UID %q", title) ctx := appcontext.WithUser(context.Background(), user) - folder, err := s.Create(ctx, &folder.CreateFolderCommand{OrgID: user.OrgID, Title: title, UID: title}) + folder, err := s.Create(ctx, &folder.CreateFolderCommand{OrgID: user.OrgID, Title: title, UID: title, SignedInUser: user}) require.NoError(t, err) updateFolderACL(t, dashboardStore, folder.ID, items) diff --git a/pkg/services/live/pipeline/devdata.go b/pkg/services/live/pipeline/devdata.go index 3d6b8ad7e54..db7c278bf79 100644 --- a/pkg/services/live/pipeline/devdata.go +++ b/pkg/services/live/pipeline/devdata.go @@ -32,7 +32,7 @@ type Data struct { func postTestData() { i := 0 for { - time.Sleep(1000 * time.Millisecond) + time.Sleep(time.Second) num1 := rand.Intn(10) num2 := rand.Intn(10) d := Data{ diff --git a/pkg/services/live/runstream/manager.go b/pkg/services/live/runstream/manager.go index 8e96da34cfb..f4ab4d7b134 100644 --- a/pkg/services/live/runstream/manager.go +++ b/pkg/services/live/runstream/manager.go @@ -75,7 +75,7 @@ func WithCheckConfig(interval time.Duration, maxChecks int) ManagerOption { const ( defaultCheckInterval = 5 * time.Second - defaultDatasourceCheckInterval = 60 * time.Second + defaultDatasourceCheckInterval = time.Minute defaultMaxChecks = 3 ) diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 3568b883c46..e8484c781ba 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -13,6 +13,7 @@ type AuthInfoService interface { GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error + DeleteUserAuthInfo(ctx context.Context, userID int64) error } const ( diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index 395818e662e..a7fdf2fd65c 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -218,6 +218,14 @@ func (s *AuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAu }) } +func (s *AuthInfoStore) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "DELETE FROM user_auth WHERE user_id = ?" + _, err := sess.Exec(rawSQL, userID) + return err + }) +} + func (s *AuthInfoStore) GetUserById(ctx context.Context, id int64) (*user.User, error) { query := user.GetUserByIDQuery{ID: id} user, err := s.userService.GetByID(ctx, &query) diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index ac713a7345b..3a6025bd491 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -197,6 +197,10 @@ func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query * return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query) } +func (s *Implementation) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return nil +} + func (s *Implementation) Run(ctx context.Context) error { s.logger.Debug("Started AuthInfo Metrics collection service") return s.authInfoStore.RunMetricsCollection(ctx) diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 1c28ac1423c..44323ead976 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" ) @@ -19,7 +18,6 @@ var ( ) func ProvideService( - sqlStore sqlstore.Store, userService user.Service, quotaService quota.Service, authInfoService login.AuthInfoService, @@ -27,7 +25,6 @@ func ProvideService( orgService org.Service, ) *Implementation { s := &Implementation{ - SQLStore: sqlStore, userService: userService, QuotaService: quotaService, AuthInfoService: authInfoService, @@ -38,7 +35,6 @@ func ProvideService( } type Implementation struct { - SQLStore sqlstore.Store userService user.Service AuthInfoService login.AuthInfoService QuotaService quota.Service @@ -49,7 +45,7 @@ type Implementation struct { // CreateUser creates inserts a new one. func (ls *Implementation) CreateUser(cmd user.CreateUserCommand) (*user.User, error) { - return ls.SQLStore.CreateUser(context.Background(), cmd) + return ls.userService.Create(context.Background(), &cmd) } // UpsertUser updates an existing user, or if it doesn't exist, inserts a new one. diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index edd9bade8d6..7b79d7ccc94 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -28,7 +28,6 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) login := Implementation{ QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, - SQLStore: nil, userService: usertest.NewUserServiceFake(), orgService: orgtest.NewOrgServiceFake(), } @@ -53,7 +52,6 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { login := Implementation{ QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, - SQLStore: nil, userService: usertest.NewUserServiceFake(), orgService: orgService, } diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index 5c2ce4005df..023ae063f13 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -57,6 +57,10 @@ func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, qu return a.ExpectedError } +func (a *AuthInfoServiceFake) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return a.ExpectedError +} + type AuthenticatorFake struct { ExpectedUser *user.User ExpectedError error diff --git a/pkg/services/loginattempt/login_attempt.go b/pkg/services/loginattempt/login_attempt.go index cbf581a3d59..ce3233f0f3b 100644 --- a/pkg/services/loginattempt/login_attempt.go +++ b/pkg/services/loginattempt/login_attempt.go @@ -2,12 +2,21 @@ package loginattempt import ( "context" - - "github.com/grafana/grafana/pkg/models" ) type Service interface { - CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error - DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error - GetUserLoginAttemptCount(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error + // Add adds a new login attempt record for provided username + Add(ctx context.Context, username, IPAddress string) error + // Validate checks if username has to many login attempts inside a window. + // Will return true if provided username do not have too many attempts. + Validate(ctx context.Context, username string) (bool, error) + // Reset resets all login attempts attached to username + Reset(ctx context.Context, username string) error +} + +type LoginAttempt struct { + Id int64 + Username string + IpAddress string + Created int64 } diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go index c9329d4cc84..76a07d6305e 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go @@ -5,40 +5,99 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/loginattempt" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/setting" ) -type Service struct { - store store -} +const ( + maxInvalidLoginAttempts int64 = 5 + loginAttemptsWindow = time.Minute * 5 +) -func ProvideService(db db.DB) loginattempt.Service { +func ProvideService(db db.DB, cfg *setting.Cfg, lock *serverlock.ServerLockService) *Service { return &Service{ - store: &xormStore{db: db, now: time.Now}, + &xormStore{db: db, now: time.Now}, + cfg, + lock, + log.New("login_attempt"), } } -func (s *Service) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { - err := s.store.CreateLoginAttempt(ctx, cmd) - if err != nil { - return err - } - return nil +type Service struct { + store store + cfg *setting.Cfg + lock *serverlock.ServerLockService + logger log.Logger } -func (s *Service) DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error { - err := s.store.DeleteOldLoginAttempts(ctx, cmd) - if err != nil { - return err +func (s *Service) Run(ctx context.Context) error { + // no need to run clean up job if it is disabled + if s.cfg.DisableBruteForceLoginProtection { + return nil + } + + ticker := time.NewTicker(time.Minute * 10) + for { + select { + case <-ticker.C: + s.cleanup(ctx) + case <-ctx.Done(): + return ctx.Err() + } } - return nil } -func (s *Service) GetUserLoginAttemptCount(ctx context.Context, cmd *models.GetUserLoginAttemptCountQuery) error { - err := s.store.GetUserLoginAttemptCount(ctx, cmd) +func (s *Service) Add(ctx context.Context, username, IPAddress string) error { + if s.cfg.DisableBruteForceLoginProtection { + return nil + } + + return s.store.CreateLoginAttempt(ctx, CreateLoginAttemptCommand{ + Username: username, + IpAddress: IPAddress, + }) +} + +func (s *Service) Reset(ctx context.Context, username string) error { + return s.store.DeleteLoginAttempts(ctx, DeleteLoginAttemptsCommand{username}) +} + +func (s *Service) Validate(ctx context.Context, username string) (bool, error) { + if s.cfg.DisableBruteForceLoginProtection { + return true, nil + } + + loginAttemptCountQuery := GetUserLoginAttemptCountQuery{ + Username: username, + Since: time.Now().Add(-loginAttemptsWindow), + } + + count, err := s.store.GetUserLoginAttemptCount(ctx, loginAttemptCountQuery) if err != nil { - return err + return false, err + } + + if count >= maxInvalidLoginAttempts { + return false, nil + } + + return true, nil +} + +func (s *Service) cleanup(ctx context.Context) { + err := s.lock.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func(context.Context) { + cmd := DeleteOldLoginAttemptsCommand{ + OlderThan: time.Now().Add(time.Minute * -10), + } + if deletedLogs, err := s.store.DeleteOldLoginAttempts(ctx, cmd); err != nil { + s.logger.Error("Problem deleting expired login attempts", "error", err.Error()) + } else { + s.logger.Debug("Deleted expired login attempts", "rows affected", deletedLogs) + } + }) + + if err != nil { + s.logger.Error("failed to lock and execute cleanup of old login attempts", "error", err) } - return nil } diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go new file mode 100644 index 00000000000..84e93e0049f --- /dev/null +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go @@ -0,0 +1,102 @@ +package loginattemptimpl + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +func TestService_Validate(t *testing.T) { + testCases := []struct { + name string + loginAttempts int64 + disabled bool + expected bool + expectedErr error + }{ + { + name: "When brute force protection enabled and user login attempt count is less than max", + loginAttempts: maxInvalidLoginAttempts - 1, + expected: true, + expectedErr: nil, + }, + { + name: "When brute force protection enabled and user login attempt count equals max", + loginAttempts: maxInvalidLoginAttempts, + expected: false, + expectedErr: nil, + }, + { + name: "When brute force protection enabled and user login attempt count is greater than max", + loginAttempts: maxInvalidLoginAttempts + 1, + expected: false, + expectedErr: nil, + }, + + { + name: "When brute force protection disabled and user login attempt count is less than max", + loginAttempts: maxInvalidLoginAttempts - 1, + disabled: true, + expected: true, + expectedErr: nil, + }, + { + name: "When brute force protection disabled and user login attempt count equals max", + loginAttempts: maxInvalidLoginAttempts, + disabled: true, + expected: true, + expectedErr: nil, + }, + { + name: "When brute force protection disabled and user login attempt count is greater than max", + loginAttempts: maxInvalidLoginAttempts + 1, + disabled: true, + expected: true, + expectedErr: nil, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + cfg := setting.NewCfg() + cfg.DisableBruteForceLoginProtection = tt.disabled + service := &Service{ + store: fakeStore{ + ExpectedCount: tt.loginAttempts, + ExpectedErr: tt.expectedErr, + }, + cfg: cfg, + } + + ok, err := service.Validate(context.Background(), "test") + assert.Equal(t, tt.expected, ok) + assert.Equal(t, tt.expectedErr, err) + }) + } +} + +var _ store = new(fakeStore) + +type fakeStore struct { + ExpectedErr error + ExpectedCount int64 + ExpectedDeletedRows int64 +} + +func (f fakeStore) GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error) { + return f.ExpectedCount, f.ExpectedErr +} + +func (f fakeStore) CreateLoginAttempt(ctx context.Context, command CreateLoginAttemptCommand) error { + return f.ExpectedErr +} + +func (f fakeStore) DeleteOldLoginAttempts(ctx context.Context, command DeleteOldLoginAttemptsCommand) (int64, error) { + return f.ExpectedDeletedRows, f.ExpectedErr +} + +func (f fakeStore) DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error { + return f.ExpectedErr +} diff --git a/pkg/services/loginattempt/loginattemptimpl/models.go b/pkg/services/loginattempt/loginattemptimpl/models.go new file mode 100644 index 00000000000..85fc944f577 --- /dev/null +++ b/pkg/services/loginattempt/loginattemptimpl/models.go @@ -0,0 +1,27 @@ +package loginattemptimpl + +import ( + "time" + + "github.com/grafana/grafana/pkg/services/loginattempt" +) + +type CreateLoginAttemptCommand struct { + Username string + IpAddress string + + Result loginattempt.LoginAttempt +} + +type GetUserLoginAttemptCountQuery struct { + Username string + Since time.Time +} + +type DeleteOldLoginAttemptsCommand struct { + OlderThan time.Time +} + +type DeleteLoginAttemptsCommand struct { + Username string +} diff --git a/pkg/services/loginattempt/loginattemptimpl/store.go b/pkg/services/loginattempt/loginattemptimpl/store.go index 2881bba4e2a..75456d7a9f8 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store.go +++ b/pkg/services/loginattempt/loginattemptimpl/store.go @@ -2,11 +2,10 @@ package loginattemptimpl import ( "context" - "strconv" "time" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/loginattempt" ) type xormStore struct { @@ -15,14 +14,15 @@ type xormStore struct { } type store interface { - CreateLoginAttempt(context.Context, *models.CreateLoginAttemptCommand) error - DeleteOldLoginAttempts(context.Context, *models.DeleteOldLoginAttemptsCommand) error - GetUserLoginAttemptCount(context.Context, *models.GetUserLoginAttemptCountQuery) error + CreateLoginAttempt(ctx context.Context, cmd CreateLoginAttemptCommand) error + DeleteOldLoginAttempts(ctx context.Context, cmd DeleteOldLoginAttemptsCommand) (int64, error) + DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error + GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error) } -func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { +func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd CreateLoginAttemptCommand) error { return xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - loginAttempt := models.LoginAttempt{ + loginAttempt := loginattempt.LoginAttempt{ Username: cmd.Username, IpAddress: cmd.IpAddress, Created: xs.now().Unix(), @@ -38,63 +38,46 @@ func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd *models.CreateL }) } -func (xs *xormStore) DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error { - return xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - var maxId int64 - sql := "SELECT max(id) as id FROM login_attempt WHERE created < ?" - result, err := sess.Query(sql, cmd.OlderThan.Unix()) +func (xs *xormStore) DeleteOldLoginAttempts(ctx context.Context, cmd DeleteOldLoginAttemptsCommand) (int64, error) { + var deletedRows int64 + err := xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + deleteResult, err := sess.Exec("DELETE FROM login_attempt WHERE created < ?", cmd.OlderThan.Unix()) if err != nil { return err } - if len(result) == 0 || result[0] == nil { - return nil - } - // TODO: why don't we know the type of ID? - maxId = toInt64(result[0]["id"]) - - if maxId == 0 { - return nil - } - - sql = "DELETE FROM login_attempt WHERE id <= ?" - - if result, err := sess.Exec(sql, maxId); err != nil { - return err - } else if cmd.DeletedRows, err = result.RowsAffected(); err != nil { + deletedRows, err = deleteResult.RowsAffected() + if err != nil { return err } - return nil }) + return deletedRows, err +} + +func (xs *xormStore) DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error { + return xs.db.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Exec("DELETE FROM login_attempt WHERE username = ?", cmd.Username) + return err + }) } -func (xs *xormStore) GetUserLoginAttemptCount(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error { - return xs.db.WithDbSession(ctx, func(dbSession *db.Session) error { - loginAttempt := new(models.LoginAttempt) - total, err := dbSession. +func (xs *xormStore) GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error) { + var total int64 + err := xs.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var queryErr error + loginAttempt := new(loginattempt.LoginAttempt) + total, queryErr = dbSession. Where("username = ?", query.Username). And("created >= ?", query.Since.Unix()). Count(loginAttempt) - if err != nil { - return err + if queryErr != nil { + return queryErr } - query.Result = total return nil }) -} -func toInt64(i interface{}) int64 { - switch i := i.(type) { - case []byte: - n, _ := strconv.ParseInt(string(i), 10, 64) - return n - case int: - return int64(i) - case int64: - return i - } - return 0 + return total, err } diff --git a/pkg/services/loginattempt/loginattemptimpl/store_test.go b/pkg/services/loginattempt/loginattemptimpl/store_test.go index e0c5af8dafb..12f4ed58bfb 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/store_test.go @@ -8,15 +8,12 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/loginattempt" ) func TestIntegrationLoginAttemptsQuery(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var loginAttemptService loginattempt.Service user := "user" beginningOfTime := time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local) @@ -25,58 +22,60 @@ func TestIntegrationLoginAttemptsQuery(t *testing.T) { for _, test := range []struct { Name string - Query models.GetUserLoginAttemptCountQuery + Query GetUserLoginAttemptCountQuery Err error Result int64 }{ { "Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", - models.GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 0, + GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 0, }, { "Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", - models.GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 0, + GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 0, }, { "Should return the total count of login attempts since beginning of time", - models.GetUserLoginAttemptCountQuery{Username: user, Since: beginningOfTime}, nil, 3, + GetUserLoginAttemptCountQuery{Username: user, Since: beginningOfTime}, nil, 3, }, { "Should return the total count of login attempts since beginning of time + 1min", - models.GetUserLoginAttemptCountQuery{Username: user, Since: timePlusOneMinute}, nil, 2, + GetUserLoginAttemptCountQuery{Username: user, Since: timePlusOneMinute}, nil, 2, }, { "Should return the total count of login attempts since beginning of time + 2min", - models.GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes}, nil, 1, + GetUserLoginAttemptCountQuery{Username: user, Since: timePlusTwoMinutes}, nil, 1, }, } { mockTime := beginningOfTime - loginAttemptService = &Service{ - store: &xormStore{ - db: db.InitTestDB(t), - now: func() time.Time { return mockTime }, - }, + s := &xormStore{ + db: db.InitTestDB(t), + now: func() time.Time { return mockTime }, } - err := loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + + err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) + mockTime = timePlusOneMinute - err = loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) + mockTime = timePlusTwoMinutes - err = loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) - err = loginAttemptService.GetUserLoginAttemptCount(context.Background(), &test.Query) + + count, err := s.GetUserLoginAttemptCount(context.Background(), test.Query) require.Equal(t, test.Err, err, test.Name) - require.Equal(t, test.Result, test.Query.Result, test.Name) + require.Equal(t, test.Result, count, test.Name) } } @@ -84,7 +83,6 @@ func TestIntegrationLoginAttemptsDelete(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var loginAttemptService loginattempt.Service user := "user" beginningOfTime := time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local) @@ -93,53 +91,55 @@ func TestIntegrationLoginAttemptsDelete(t *testing.T) { for _, test := range []struct { Name string - Cmd models.DeleteOldLoginAttemptsCommand + Cmd DeleteOldLoginAttemptsCommand Err error DeletedRows int64 }{ { "Should return deleted rows older than beginning of time", - models.DeleteOldLoginAttemptsCommand{OlderThan: beginningOfTime}, nil, 0, + DeleteOldLoginAttemptsCommand{OlderThan: beginningOfTime}, nil, 0, }, { "Should return deleted rows older than beginning of time + 1min", - models.DeleteOldLoginAttemptsCommand{OlderThan: timePlusOneMinute}, nil, 1, + DeleteOldLoginAttemptsCommand{OlderThan: timePlusOneMinute}, nil, 1, }, { "Should return deleted rows older than beginning of time + 2min", - models.DeleteOldLoginAttemptsCommand{OlderThan: timePlusTwoMinutes}, nil, 2, + DeleteOldLoginAttemptsCommand{OlderThan: timePlusTwoMinutes}, nil, 2, }, { "Should return deleted rows older than beginning of time + 2min and 1s", - models.DeleteOldLoginAttemptsCommand{OlderThan: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 3, + DeleteOldLoginAttemptsCommand{OlderThan: timePlusTwoMinutes.Add(time.Second * 1)}, nil, 3, }, } { mockTime := beginningOfTime - loginAttemptService = &Service{ - store: &xormStore{ - db: db.InitTestDB(t), - now: func() time.Time { return mockTime }, - }, + s := &xormStore{ + db: db.InitTestDB(t), + now: func() time.Time { return mockTime }, } - err := loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + + err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) + mockTime = timePlusOneMinute - err = loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) + mockTime = timePlusTwoMinutes - err = loginAttemptService.CreateLoginAttempt(context.Background(), &models.CreateLoginAttemptCommand{ + err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, IpAddress: "192.168.0.1", }) require.Nil(t, err) - err = loginAttemptService.DeleteOldLoginAttempts(context.Background(), &test.Cmd) + + deletedRows, err := s.DeleteOldLoginAttempts(context.Background(), test.Cmd) require.Equal(t, test.Err, err, test.Name) - require.Equal(t, test.DeletedRows, test.Cmd.DeletedRows, test.Name) + require.Equal(t, test.DeletedRows, deletedRows, test.Name) } } diff --git a/pkg/services/loginattempt/loginattempttest/fake.go b/pkg/services/loginattempt/loginattempttest/fake.go new file mode 100644 index 00000000000..581aeae1742 --- /dev/null +++ b/pkg/services/loginattempt/loginattempttest/fake.go @@ -0,0 +1,26 @@ +package loginattempttest + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/loginattempt" +) + +var _ loginattempt.Service = new(FakeLoginAttemptService) + +type FakeLoginAttemptService struct { + ExpectedValid bool + ExpectedErr error +} + +func (f FakeLoginAttemptService) Add(ctx context.Context, username, IPAddress string) error { + return f.ExpectedErr +} + +func (f FakeLoginAttemptService) Reset(ctx context.Context, username string) error { + return f.ExpectedErr +} + +func (f FakeLoginAttemptService) Validate(ctx context.Context, username string) (bool, error) { + return f.ExpectedValid, f.ExpectedErr +} diff --git a/pkg/services/loginattempt/loginattempttest/mock.go b/pkg/services/loginattempt/loginattempttest/mock.go new file mode 100644 index 00000000000..c775323a9a3 --- /dev/null +++ b/pkg/services/loginattempt/loginattempttest/mock.go @@ -0,0 +1,33 @@ +package loginattempttest + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/loginattempt" +) + +var _ loginattempt.Service = new(MockLoginAttemptService) + +type MockLoginAttemptService struct { + AddCalled bool + ResetCalled bool + ValidateCalled bool + + ExpectedValid bool + ExpectedErr error +} + +func (f *MockLoginAttemptService) Add(ctx context.Context, username, IPAddress string) error { + f.AddCalled = true + return f.ExpectedErr +} + +func (f *MockLoginAttemptService) Reset(ctx context.Context, username string) error { + f.ResetCalled = true + return f.ExpectedErr +} + +func (f *MockLoginAttemptService) Validate(ctx context.Context, username string) (bool, error) { + f.ValidateCalled = true + return f.ExpectedValid, f.ExpectedErr +} diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index f71ceea0808..e73929d123a 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -36,10 +36,14 @@ const ( ) const ( + NavIDRoot = "root" NavIDDashboards = "dashboards" NavIDDashboardsBrowse = "dashboards/browse" NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node NavIDAdmin = "admin" + NavIDAdminGeneral = "admin/general" + NavIDAdminPlugins = "admin/plugins" + NavIDAdminAccess = "admin/access" NavIDAlertsAndIncidents = "alerts-and-incidents" NavIDAlerting = "alerting" NavIDMonitoring = "monitoring" @@ -110,22 +114,7 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t } if topNavEnabled { - orgAdminNode := root.FindById(NavIDCfg) - - if orgAdminNode != nil { - orgAdminNode.Url = "/admin" - orgAdminNode.Text = "Administration" - } - - if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil { - serverAdminNode.Url = "/admin/server" - serverAdminNode.SortWeight = 0 - - if orgAdminNode != nil { - orgAdminNode.Children = append(orgAdminNode.Children, serverAdminNode) - root.RemoveSection(serverAdminNode) - } - } + ApplyAdminIA(root) // Move reports into dashboards if reports := root.FindById(NavIDReporting); reports != nil { @@ -151,6 +140,10 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t node.Url = node.Children[0].Url } } + + if len(root.Children) < 1 { + root.Children = make([]*NavLink, 0) + } } func (root *NavTreeRoot) Sort() { @@ -180,6 +173,99 @@ func Sort(nodes []*NavLink) { } } +func ApplyAdminIA(root *NavTreeRoot) { + orgAdminNode := root.FindById(NavIDCfg) + + if orgAdminNode != nil { + orgAdminNode.Url = "/admin" + orgAdminNode.Text = "Administration" + + generalNodeLinks := []*NavLink{} + pluginsNodeLinks := []*NavLink{} + accessNodeLinks := []*NavLink{} + + generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("upgrading")) + if orgSettings := root.FindById("org-settings"); orgSettings != nil { + orgSettings.Text = "Default preferences" + generalNodeLinks = append(generalNodeLinks, orgSettings) + } + generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("global-orgs")) + generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("server-settings")) + + pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugins")) + pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("datasources")) + pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("correlations")) + pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app")) + pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries")) // enterprise only + + accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("users")) + if globalUsers := root.FindById("global-users"); globalUsers != nil { + globalUsers.Text = "Users (All orgs)" + accessNodeLinks = append(accessNodeLinks, globalUsers) + } + accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams")) + accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("serviceaccounts")) + accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("apikeys")) + accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app")) // Cloud Access Policies + + generalNode := &NavLink{ + Text: "General", + Id: NavIDAdminGeneral, + Url: "/admin/general", + Icon: "shield", + Children: generalNodeLinks, + } + + pluginsNode := &NavLink{ + Text: "Plugins and data", + Id: NavIDAdminPlugins, + Url: "/admin/plugins", + Icon: "shield", + Children: pluginsNodeLinks, + } + + accessNode := &NavLink{ + Text: "Users and access", + Id: NavIDAdminAccess, + Url: "/admin/access", + Icon: "shield", + Children: accessNodeLinks, + } + + adminNodeLinks := []*NavLink{} + + if len(generalNode.Children) > 0 { + adminNodeLinks = append(adminNodeLinks, generalNode) + } + + if len(pluginsNode.Children) > 0 { + adminNodeLinks = append(adminNodeLinks, pluginsNode) + } + + if len(accessNode.Children) > 0 { + adminNodeLinks = append(adminNodeLinks, accessNode) + } + + if len(adminNodeLinks) > 0 { + orgAdminNode.Children = adminNodeLinks + } else { + root.RemoveSection(orgAdminNode) + } + } + + if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil { + root.RemoveSection(serverAdminNode) + } +} + +func AppendIfNotNil(children []*NavLink, newChild *NavLink) []*NavLink { + if newChild != nil { + return append(children, newChild) + } + + return children +} + func FindById(nodes []*NavLink, id string) *NavLink { for _, child := range nodes { if child.Id == id { diff --git a/pkg/services/navtree/models_test.go b/pkg/services/navtree/models_test.go index 21cd52a8dce..66f8a01ab55 100644 --- a/pkg/services/navtree/models_test.go +++ b/pkg/services/navtree/models_test.go @@ -33,18 +33,20 @@ func TestNavTreeRoot(t *testing.T) { require.Equal(t, 2, len(treeRoot.Children)) }) - t.Run("Should move admin section into cfg and rename when topnav is enabled", func(t *testing.T) { + t.Run("Should create 3 new sections in the Admin node when topnav is enabled", func(t *testing.T) { treeRoot := NavTreeRoot{ Children: []*NavLink{ {Id: NavIDCfg}, - {Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}}, + {Id: NavIDAdmin, Children: []*NavLink{{Id: "upgrading"}, {Id: "plugins"}, {Id: "teams"}}}, }, } treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true) require.Equal(t, "Administration", treeRoot.Children[0].Text) - require.Equal(t, NavIDAdmin, treeRoot.Children[0].Children[0].Id) + require.Equal(t, NavIDAdminGeneral, treeRoot.Children[0].Children[0].Id) + require.Equal(t, NavIDAdminPlugins, treeRoot.Children[0].Children[1].Id) + require.Equal(t, NavIDAdminAccess, treeRoot.Children[0].Children[2].Id) }) t.Run("Should move reports into Dashboards", func(t *testing.T) { diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index c024aeb7f97..7bf7a95ec46 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -166,16 +166,8 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink Children: adminNavLinks, } - if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagTopnav) { - adminNode.SubTitle = "Manage server-wide settings and access to resources such as organizations, users, and licenses" - } - if len(adminNavLinks) > 0 { - if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagTopnav) { - adminNode.Url = s.cfg.AppSubURL + "/admin/server" - } else { - adminNode.Url = adminNavLinks[0].Url - } + adminNode.Url = adminNavLinks[0].Url } return adminNode diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 22c0aef5acf..e59a4d6bd09 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -170,6 +170,12 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo } appLink.Children = childrenWithoutDefault + s.addPluginToSection(c, treeRoot, plugin, appLink) + + return nil +} + +func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree.NavTreeRoot, plugin plugins.PluginDTO, appLink *navtree.NavLink) { // Handle moving apps into specific navtree sections alertingNode := treeRoot.FindById(navtree.NavIDAlerting) sectionID := navtree.NavIDApps @@ -183,7 +189,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo } } - if navNode := treeRoot.FindById(sectionID); navNode != nil { + if sectionID == navtree.NavIDRoot { + treeRoot.AddSection(appLink) + } else if navNode := treeRoot.FindById(sectionID); navNode != nil { navNode.Children = append(navNode.Children, appLink) } else { switch sectionID { @@ -227,8 +235,6 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", sectionID) } } - - return nil } func (s *ServiceImpl) hasAccessToInclude(c *models.ReqContext, pluginID string) func(include *plugins.Includes) bool { @@ -256,6 +262,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, + "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightSavedItems + 1, Text: "Connections"}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index de6ffacf500..f0ed89967ba 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -147,6 +147,30 @@ func TestAddAppLinks(t *testing.T) { require.Equal(t, "Page2", app1Node.Children[0].Text) }) + // This can be done by using `[navigation.app_sections]` in the INI config + t.Run("Should move apps that have root nav id configured to the root", func(t *testing.T) { + service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) + service.navigationAppConfig = map[string]NavigationAppConfig{ + "test-app1": {SectionID: navtree.NavIDRoot}, + } + + treeRoot := navtree.NavTreeRoot{} + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + + // Check if the plugin gets moved to the root + require.Len(t, treeRoot.Children, 2) + require.Equal(t, "plugin-page-test-app1", treeRoot.Children[0].Id) + + // Check if it is not under the "Apps" section anymore + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.NotNil(t, appsNode) + require.Len(t, appsNode.Children, 2) + require.Equal(t, "plugin-page-test-app2", appsNode.Children[0].Id) + require.Equal(t, "plugin-page-test-app3", appsNode.Children[1].Id) + }) + // This can be done by using `[navigation.app_sections]` in the INI config t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 73d601b6ba3..16f0a5e231d 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -73,9 +73,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs * hasAccess := ac.HasAccess(s.accessControl, c) treeRoot := &navtree.NavTreeRoot{} - if s.features.IsEnabled(featuremgmt.FlagTopnav) { - treeRoot.AddSection(s.getHomeNode(c, prefs)) - } + treeRoot.AddSection(s.getHomeNode(c, prefs)) if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) { starredItemsLinks, err := s.buildStarredItemsNavLinks(c) @@ -224,7 +222,7 @@ func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference) } } - return &navtree.NavLink{ + homeNode := &navtree.NavLink{ Text: "Home", Id: "home", Url: homeUrl, @@ -232,6 +230,10 @@ func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference) Section: navtree.NavSectionCore, SortWeight: navtree.WeightHome, } + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + homeNode.HideFromMenu = true + } + return homeNode } func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) { diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index 75c86d20b39..8924b6cc2a5 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -104,10 +104,6 @@ type ProvisionedAlertRule struct { } func (a *ProvisionedAlertRule) UpstreamModel() (models.AlertRule, error) { - forDur, err := time.ParseDuration(a.For.String()) - if err != nil { - return models.AlertRule{}, err - } return models.AlertRule{ ID: a.ID, UID: a.UID, @@ -120,7 +116,7 @@ func (a *ProvisionedAlertRule) UpstreamModel() (models.AlertRule, error) { Updated: a.Updated, NoDataState: a.NoDataState, ExecErrState: a.ExecErrState, - For: forDur, + For: time.Duration(a.For), Annotations: a.Annotations, Labels: a.Labels, }, nil diff --git a/pkg/services/ngalert/api/util_test.go b/pkg/services/ngalert/api/util_test.go index 34f4deb08e2..3ed32d797e6 100644 --- a/pkg/services/ngalert/api/util_test.go +++ b/pkg/services/ngalert/api/util_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/auth" models2 "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -45,7 +46,7 @@ func TestAlertingProxy_createProxyContext(t *testing.T) { Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{}, - UserToken: &models.UserToken{}, + UserToken: &auth.UserToken{}, IsSignedIn: rand.Int63()%2 == 1, IsRenderCall: rand.Int63()%2 == 1, AllowAnonymous: rand.Int63()%2 == 1, diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index c7725c0a396..5dcbbb030f6 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -226,6 +226,7 @@ func buildDatasourceHeaders(ctx EvaluationContext) map[string]string { key, ok := models.RuleKeyFromContext(ctx.Ctx) if ok { headers["X-Rule-Uid"] = key.UID + headers["X-Grafana-Org-Id"] = strconv.FormatInt(key.OrgID, 10) } return headers diff --git a/pkg/services/ngalert/image/service.go b/pkg/services/ngalert/image/service.go index 385375537cd..4f7a36a69a2 100644 --- a/pkg/services/ngalert/image/service.go +++ b/pkg/services/ngalert/image/service.go @@ -21,8 +21,8 @@ import ( ) const ( - screenshotCacheTTL = 60 * time.Second screenshotTimeout = 10 * time.Second + screenshotCacheTTL = time.Minute ) // DeleteExpiredService is a service to delete expired images. diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 4b076ff8c31..48efde7c517 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -38,7 +38,7 @@ func setupAMTest(t *testing.T) *Alertmanager { s := &store.DBstore{ Cfg: setting.UnifiedAlertingSettings{ BaseInterval: 10 * time.Second, - DefaultRuleEvaluationInterval: 60 * time.Second, + DefaultRuleEvaluationInterval: time.Minute, }, SQLStore: sqlStore, Logger: log.New("alertmanager-test"), diff --git a/pkg/services/ngalert/notifier/channels/alertmanager.go b/pkg/services/ngalert/notifier/channels/alertmanager.go index befbb002f7c..9d5d32e4531 100644 --- a/pkg/services/ngalert/notifier/channels/alertmanager.go +++ b/pkg/services/ngalert/notifier/channels/alertmanager.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "strings" @@ -34,18 +33,21 @@ func NewAlertmanagerConfig(config *NotificationChannelConfig, fn GetDecryptedVal if urlStr == "" { return nil, errors.New("could not find url property in settings") } - var urls []*url.URL - for _, uS := range strings.Split(urlStr, ",") { + + urlParts := strings.Split(urlStr, ",") + urls := make([]*url.URL, 0, len(urlParts)) + + for _, uS := range urlParts { uS = strings.TrimSpace(uS) if uS == "" { continue } uS = strings.TrimSuffix(uS, "/") + "/api/v1/alerts" - url, err := url.Parse(uS) + u, err := url.Parse(uS) if err != nil { return nil, fmt.Errorf("invalid url property in settings: %w", err) } - urls = append(urls, url) + urls = append(urls, u) } return &AlertmanagerConfig{ NotificationChannelConfig: config, diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index da621f7e646..d29bc1273f0 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -189,9 +189,13 @@ func TestEmailNotifierIntegration(t *testing.T) { messageTmpl: "", expSubject: "[FIRING:2] ", expSnippets: []string{ - "Firing: 2 alerts", - "
  • alertname: FiringOne
  • severity: warning
  • ", - "
  • alertname: FiringTwo
  • severity: critical
  • ", + "2 firing instances", + "severity", + "warning\n", + "critical\n", + "alertname", + "FiringTwo\n", + "FiringOne\n", "", + "apiToken": "", + "device": "device", + "priority": 2, + "okpriority": 0, + "retry": 30, + "expire": 86400, + "sound": "echo", + "oksound": "magic", + "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, + }, + }, + }, + expMsg: map[string]string{ + "user": "", + "token": "", + "priority": "2", + "sound": "echo", + "title": "[FIRING:2] ", + "url": "http://localhost/alerting/list", + "url_title": "Show alert rule", + "message": "2 alerts are firing, 0 are resolved", + "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", + "html": "1", + "retry": "30", + "expire": "86400", + "device": "device", + }, + expMsgError: nil, + }, { name: "Missing user key", settings: `{ @@ -186,7 +230,8 @@ func TestPushoverNotifier(t *testing.T) { DecryptFunc: decryptFn, Template: tmpl, } - pn, err := newPushoverNotifier(fc) + + pn, err := NewPushoverNotifier(fc) if c.expInitError != "" { require.Error(t, err) require.Equal(t, c.expInitError, err.Error()) diff --git a/pkg/services/ngalert/notifier/channels/sensugo.go b/pkg/services/ngalert/notifier/channels/sensugo.go index 66de07cf0a4..ead78de5a36 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo.go +++ b/pkg/services/ngalert/notifier/channels/sensugo.go @@ -19,86 +19,74 @@ import ( type SensuGoNotifier struct { *Base - log log.Logger - images ImageStore - ns notifications.WebhookSender - tmpl *template.Template - - URL string - Entity string - Check string - Namespace string - Handler string - APIKey string - Message string + log log.Logger + images ImageStore + ns notifications.WebhookSender + tmpl *template.Template + settings sensuGoSettings } -type SensuGoConfig struct { - *NotificationChannelConfig - URL string - Entity string - Check string - Namespace string - Handler string - APIKey string - Message string +type sensuGoSettings struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` + Check string `json:"check,omitempty" yaml:"check,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Handler string `json:"handler,omitempty" yaml:"handler,omitempty"` + APIKey string `json:"apikey,omitempty" yaml:"apikey,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` +} + +func buildSensuGoConfig(fc FactoryConfig) (sensuGoSettings, error) { + settings := sensuGoSettings{} + err := fc.Config.unmarshalSettings(&settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.URL == "" { + return settings, errors.New("could not find URL property in settings") + } + settings.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apikey", settings.APIKey) + if settings.APIKey == "" { + return settings, errors.New("could not find the API key property in settings") + } + if settings.Message == "" { + settings.Message = DefaultMessageEmbed + } + return settings, nil } func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { - cfg, err := NewSensuGoConfig(fc.Config, fc.DecryptFunc) + notifier, err := NewSensuGoNotifier(fc) if err != nil { return nil, receiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } } - return NewSensuGoNotifier(cfg, fc.ImageStore, fc.NotificationService, fc.Template), nil -} - -func NewSensuGoConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*SensuGoConfig, error) { - url := config.Settings.Get("url").MustString() - if url == "" { - return nil, errors.New("could not find URL property in settings") - } - apikey := decryptFunc(context.Background(), config.SecureSettings, "apikey", config.Settings.Get("apikey").MustString()) - if apikey == "" { - return nil, errors.New("could not find the API key property in settings") - } - return &SensuGoConfig{ - NotificationChannelConfig: config, - URL: url, - Entity: config.Settings.Get("entity").MustString(), - Check: config.Settings.Get("check").MustString(), - Namespace: config.Settings.Get("namespace").MustString(), - Handler: config.Settings.Get("handler").MustString(), - APIKey: apikey, - Message: config.Settings.Get("message").MustString(DefaultMessageEmbed), - }, nil + return notifier, nil } // NewSensuGoNotifier is the constructor for the SensuGo notifier -func NewSensuGoNotifier(config *SensuGoConfig, images ImageStore, ns notifications.WebhookSender, t *template.Template) *SensuGoNotifier { +func NewSensuGoNotifier(fc FactoryConfig) (*SensuGoNotifier, error) { + settings, err := buildSensuGoConfig(fc) + if err != nil { + return nil, err + } return &SensuGoNotifier{ Base: NewBase(&models.AlertNotification{ - Uid: config.UID, - Name: config.Name, - Type: config.Type, - DisableResolveMessage: config.DisableResolveMessage, - Settings: config.Settings, - SecureSettings: config.SecureSettings, + Uid: fc.Config.UID, + Name: fc.Config.Name, + Type: fc.Config.Type, + DisableResolveMessage: fc.Config.DisableResolveMessage, + Settings: fc.Config.Settings, + SecureSettings: fc.Config.SecureSettings, }), - URL: config.URL, - Entity: config.Entity, - Check: config.Check, - Namespace: config.Namespace, - Handler: config.Handler, - APIKey: config.APIKey, - Message: config.Message, - log: log.New("alerting.notifier.sensugo"), - images: images, - ns: ns, - tmpl: t, - } + log: log.New("alerting.notifier.sensugo"), + images: fc.ImageStore, + ns: fc.NotificationService, + tmpl: fc.Template, + settings: settings, + }, nil } // Notify sends an alert notification to Sensu Go @@ -110,12 +98,12 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool // Sensu Go alerts require an entity and a check. We set it to the user-specified // value (optional), else we fallback and use the grafana rule anme and ruleID. - entity := tmpl(sn.Entity) + entity := tmpl(sn.settings.Entity) if entity == "" { entity = "default" } - check := tmpl(sn.Check) + check := tmpl(sn.settings.Check) if check == "" { check = "default" } @@ -127,14 +115,14 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool status = 2 } - namespace := tmpl(sn.Namespace) + namespace := tmpl(sn.settings.Namespace) if namespace == "" { namespace = "default" } var handlers []string - if sn.Handler != "" { - handlers = []string{tmpl(sn.Handler)} + if sn.settings.Handler != "" { + handlers = []string{tmpl(sn.settings.Handler)} } labels := make(map[string]string) @@ -166,7 +154,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool "name": check, "labels": labels, }, - "output": tmpl(sn.Message), + "output": tmpl(sn.settings.Message), "issued": timeNow().Unix(), "interval": 86400, "status": status, @@ -185,12 +173,12 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool } cmd := &models.SendWebhookSync{ - Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.URL, "/"), namespace), + Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.settings.URL, "/"), namespace), Body: string(body), HttpMethod: "POST", HttpHeader: map[string]string{ "Content-Type": "application/json", - "Authorization": fmt.Sprintf("Key %s", sn.APIKey), + "Authorization": fmt.Sprintf("Key %s", sn.settings.APIKey), }, } if err := sn.ns.SendWebhookSync(ctx, cmd); err != nil { diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go index ea0a8a9a863..74bbdf0c5fa 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ b/pkg/services/ngalert/notifier/channels/sensugo_test.go @@ -151,7 +151,16 @@ func TestSensuGoNotifier(t *testing.T) { webhookSender := mockNotificationService() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - cfg, err := NewSensuGoConfig(m, decryptFn) + + fc := FactoryConfig{ + Config: m, + ImageStore: images, + NotificationService: webhookSender, + Template: tmpl, + DecryptFunc: decryptFn, + } + + sn, err := NewSensuGoNotifier(fc) if c.expInitError != "" { require.Error(t, err) require.Equal(t, c.expInitError, err.Error()) @@ -161,7 +170,6 @@ func TestSensuGoNotifier(t *testing.T) { ctx := notify.WithGroupKey(context.Background(), "alertname") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - sn := NewSensuGoNotifier(cfg, images, webhookSender, tmpl) ok, err := sn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.False(t, ok) diff --git a/pkg/services/ngalert/notifier/channels/teams.go b/pkg/services/ngalert/notifier/channels/teams.go index d924ca98f82..690b9f7919a 100644 --- a/pkg/services/ngalert/notifier/channels/teams.go +++ b/pkg/services/ngalert/notifier/channels/teams.go @@ -224,70 +224,71 @@ func (i AdaptiveCardOpenURLActionItem) MarshalJSON() ([]byte, error) { }) } -type TeamsConfig struct { - *NotificationChannelConfig - URL string - Message string - Title string - SectionTitle string +type teamsSettings struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + SectionTitle string `json:"sectiontitle,omitempty" yaml:"sectiontitle,omitempty"` } -func NewTeamsConfig(config *NotificationChannelConfig) (*TeamsConfig, error) { - URL := config.Settings.Get("url").MustString() - if URL == "" { - return nil, errors.New("could not find url property in settings") +func buildTeamsSettings(fc FactoryConfig) (teamsSettings, error) { + settings := teamsSettings{} + err := fc.Config.unmarshalSettings(&settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) } - return &TeamsConfig{ - NotificationChannelConfig: config, - URL: URL, - Message: config.Settings.Get("message").MustString(`{{ template "teams.default.message" .}}`), - Title: config.Settings.Get("title").MustString(DefaultMessageTitleEmbed), - SectionTitle: config.Settings.Get("sectiontitle").MustString(""), - }, nil + if settings.URL == "" { + return settings, errors.New("could not find url property in settings") + } + if settings.Message == "" { + settings.Message = `{{ template "teams.default.message" .}}` + } + if settings.Title == "" { + settings.Title = DefaultMessageTitleEmbed + } + return settings, nil } type TeamsNotifier struct { *Base - URL string - Message string - Title string - SectionTitle string - tmpl *template.Template - log log.Logger - ns notifications.WebhookSender - images ImageStore + tmpl *template.Template + log log.Logger + ns notifications.WebhookSender + images ImageStore + settings teamsSettings } // NewTeamsNotifier is the constructor for Teams notifier. -func NewTeamsNotifier(config *TeamsConfig, ns notifications.WebhookSender, images ImageStore, t *template.Template) *TeamsNotifier { +func NewTeamsNotifier(fc FactoryConfig) (*TeamsNotifier, error) { + settings, err := buildTeamsSettings(fc) + if err != nil { + return nil, err + } return &TeamsNotifier{ Base: NewBase(&models.AlertNotification{ - Uid: config.UID, - Name: config.Name, - Type: config.Type, - DisableResolveMessage: config.DisableResolveMessage, - Settings: config.Settings, + Uid: fc.Config.UID, + Name: fc.Config.Name, + Type: fc.Config.Type, + DisableResolveMessage: fc.Config.DisableResolveMessage, + Settings: fc.Config.Settings, }), - URL: config.URL, - Message: config.Message, - Title: config.Title, - SectionTitle: config.SectionTitle, - log: log.New("alerting.notifier.teams"), - ns: ns, - images: images, - tmpl: t, - } + log: log.New("alerting.notifier.teams"), + ns: fc.NotificationService, + images: fc.ImageStore, + tmpl: fc.Template, + settings: settings, + }, nil } func TeamsFactory(fc FactoryConfig) (NotificationChannel, error) { - cfg, err := NewTeamsConfig(fc.Config) + notifier, err := NewTeamsNotifier(fc) if err != nil { return nil, receiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } } - return NewTeamsNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template), nil + return notifier, nil } func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { @@ -297,13 +298,13 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, card := NewAdaptiveCard() card.AppendItem(AdaptiveCardTextBlockItem{ Color: getTeamsTextColor(types.Alerts(as...)), - Text: tmpl(tn.Title), + Text: tmpl(tn.settings.Title), Size: TextSizeLarge, Weight: TextWeightBolder, Wrap: true, }) card.AppendItem(AdaptiveCardTextBlockItem{ - Text: tmpl(tn.Message), + Text: tmpl(tn.settings.Message), Wrap: true, }) @@ -335,7 +336,7 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, }) msg := NewAdaptiveCardsMessage(card) - msg.Summary = tmpl(tn.Title) + msg.Summary = tmpl(tn.settings.Title) // This check for tmplErr must happen before templating the URL if tmplErr != nil { @@ -343,10 +344,10 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, tmplErr = nil } - u := tmpl(tn.URL) + u := tmpl(tn.settings.URL) if tmplErr != nil { - tn.log.Warn("failed to template Teams URL", "error", tmplErr.Error(), "fallback", tn.URL) - u = tn.URL + tn.log.Warn("failed to template Teams URL", "error", tmplErr.Error(), "fallback", tn.settings.URL) + u = tn.settings.URL } b, err := json.Marshal(msg) diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go index 3d0c28a10a7..b704fbb70f3 100644 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ b/pkg/services/ngalert/notifier/channels/teams_test.go @@ -289,7 +289,14 @@ func TestTeamsNotifier(t *testing.T) { clientStub := newMockClient(c.response) notifications.SetWebhookClient(clientStub) - cfg, err := NewTeamsConfig(m) + fc := FactoryConfig{ + Config: m, + ImageStore: &UnavailableImageStore{}, + NotificationService: webhookSender, + Template: tmpl, + } + + pn, err := NewTeamsNotifier(fc) if c.expInitError != "" { require.Error(t, err) require.Equal(t, c.expInitError, err.Error()) @@ -299,7 +306,7 @@ func TestTeamsNotifier(t *testing.T) { ctx := notify.WithGroupKey(context.Background(), "alertname") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - pn := NewTeamsNotifier(cfg, webhookSender, &UnavailableImageStore{}, tmpl) + ok, err := pn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.False(t, ok) diff --git a/pkg/services/ngalert/notifier/channels/webhook.go b/pkg/services/ngalert/notifier/channels/webhook.go index d5aaff281a1..e1e7cc0d0d4 100644 --- a/pkg/services/ngalert/notifier/channels/webhook.go +++ b/pkg/services/ngalert/notifier/channels/webhook.go @@ -23,55 +23,76 @@ import ( // alert notifications as webhooks. type WebhookNotifier struct { *Base - log log.Logger - ns notifications.WebhookSender - images ImageStore - tmpl *template.Template - orgID int64 - maxAlerts int - settings webhookSettings + log log.Logger + ns notifications.WebhookSender + images ImageStore + tmpl *template.Template + orgID int64 + settings webhookSettings } type webhookSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - HTTPMethod string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty"` - MaxAlerts interface{} `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty"` - + URL string + HTTPMethod string + MaxAlerts int // Authorization Header. - AuthorizationScheme string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty"` - AuthorizationCredentials string `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty"` + AuthorizationScheme string + AuthorizationCredentials string // HTTP Basic Authentication. - User string `json:"username,omitempty" yaml:"username,omitempty"` - Password string `json:"password,omitempty" yaml:"password,omitempty"` + User string + Password string - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` + Title string + Message string } func buildWebhookSettings(factoryConfig FactoryConfig) (webhookSettings, error) { settings := webhookSettings{} - err := factoryConfig.Config.unmarshalSettings(&settings) + rawSettings := struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + HTTPMethod string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty"` + MaxAlerts json.Number `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty"` + AuthorizationScheme string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty"` + AuthorizationCredentials string `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty"` + User string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + }{} + + err := factoryConfig.Config.unmarshalSettings(&rawSettings) if err != nil { return settings, fmt.Errorf("failed to unmarshal settings: %w", err) } - if settings.URL == "" { + if rawSettings.URL == "" { return settings, errors.New("required field 'url' is not specified") } - if settings.HTTPMethod == "" { - settings.HTTPMethod = http.MethodPost + settings.URL = rawSettings.URL + + if rawSettings.HTTPMethod == "" { + rawSettings.HTTPMethod = http.MethodPost } - settings.User = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "username", settings.User) - settings.Password = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "password", settings.Password) - settings.AuthorizationCredentials = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "authorization_scheme", settings.AuthorizationCredentials) + settings.HTTPMethod = rawSettings.HTTPMethod + + if rawSettings.MaxAlerts != "" { + settings.MaxAlerts, _ = strconv.Atoi(rawSettings.MaxAlerts.String()) + } + + settings.User = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "username", rawSettings.User) + settings.Password = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "password", rawSettings.Password) + settings.AuthorizationCredentials = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "authorization_scheme", rawSettings.AuthorizationCredentials) + if settings.AuthorizationCredentials != "" && settings.AuthorizationScheme == "" { settings.AuthorizationScheme = "Bearer" } if settings.User != "" && settings.Password != "" && settings.AuthorizationScheme != "" && settings.AuthorizationCredentials != "" { return settings, errors.New("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted") } + settings.Title = rawSettings.Title if settings.Title == "" { settings.Title = DefaultMessageTitleEmbed } + settings.Message = rawSettings.Message if settings.Message == "" { settings.Message = DefaultMessageEmbed } @@ -96,24 +117,6 @@ func buildWebhookNotifier(factoryConfig FactoryConfig) (*WebhookNotifier, error) if err != nil { return nil, err } - - logger := log.New("alerting.notifier.webhook") - maxAlerts := 0 - if settings.MaxAlerts != nil { - switch value := settings.MaxAlerts.(type) { - case int: - maxAlerts = value - case string: - maxAlerts, err = strconv.Atoi(value) - if err != nil { - logger.Warn("failed to convert setting maxAlerts to integer. Using default", "error", err, "original", value) - maxAlerts = 0 - } - default: - logger.Warn("unexpected type of setting maxAlerts. Expected integer. Using default", "type", fmt.Sprintf("%T", settings.MaxAlerts)) - } - } - return &WebhookNotifier{ Base: NewBase(&models.AlertNotification{ Uid: factoryConfig.Config.UID, @@ -122,13 +125,12 @@ func buildWebhookNotifier(factoryConfig FactoryConfig) (*WebhookNotifier, error) DisableResolveMessage: factoryConfig.Config.DisableResolveMessage, Settings: factoryConfig.Config.Settings, }), - orgID: factoryConfig.Config.OrgID, - log: logger, - ns: factoryConfig.NotificationService, - images: factoryConfig.ImageStore, - tmpl: factoryConfig.Template, - maxAlerts: maxAlerts, - settings: settings, + orgID: factoryConfig.Config.OrgID, + log: log.New("alerting.notifier.webhook"), + ns: factoryConfig.NotificationService, + images: factoryConfig.ImageStore, + tmpl: factoryConfig.Template, + settings: settings, }, nil } @@ -153,7 +155,7 @@ func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool return false, err } - as, numTruncated := truncateAlerts(wn.maxAlerts, as) + as, numTruncated := truncateAlerts(wn.settings.MaxAlerts, as) var tmplErr error tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 06974b44089..69a8d792093 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -2170,7 +2170,7 @@ func printAllAnnotations(annos map[int64]annotations.Item) string { func TestStaleResultsHandler(t *testing.T) { evaluationTime := time.Now() - interval := 60 * time.Second + interval := time.Minute ctx := context.Background() _, dbstore := tests.SetupTestEnv(t, 1) diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index b86037d4bc7..b99fd3e32ab 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -202,7 +202,7 @@ func TestSetEndsAt(t *testing.T) { name: "more than resend delay: for=1m,interval=5m - endsAt = interval * 3", expected: evaluationTime.Add(time.Second * 300 * 3), testRule: &ngmodels.AlertRule{ - For: 60 * time.Second, + For: time.Minute, IntervalSeconds: 300, }, }, diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 581d09594fe..6f0df1ca244 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -368,7 +368,7 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use // GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces. func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user *user.SignedInUser, withCanSave bool) (*folder.Folder, error) { - folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace}) + folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace, SignedInUser: user}) if err != nil { return nil, err } @@ -389,7 +389,7 @@ func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, org // GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces. func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user *user.SignedInUser) (*folder.Folder, error) { - folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &uid}) + folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &uid, SignedInUser: user}) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index b0db8636df4..0adc53b6e95 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -130,10 +130,10 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s } ctx = appcontext.WithUser(ctx, user) - _, err := dbstore.FolderService.Create(ctx, &folder.CreateFolderCommand{OrgID: orgID, Title: "FOLDER-" + util.GenerateShortUID(), UID: folderUID}) + _, err := dbstore.FolderService.Create(ctx, &folder.CreateFolderCommand{OrgID: orgID, Title: "FOLDER-" + util.GenerateShortUID(), UID: folderUID, SignedInUser: user}) // var foldr *folder.Folder if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) || errors.Is(err, dashboards.ErrFolderVersionMismatch) { - _, err = dbstore.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &folderUID}) + _, err = dbstore.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &folderUID, SignedInUser: user}) } require.NoError(t, err) diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index d57d1bd8a99..ab93cee5c21 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/Masterminds/sprig/v3" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/log" @@ -53,6 +54,7 @@ func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUser mailTemplates.Funcs(template.FuncMap{ "Subject": subjectTemplateFunc, }) + mailTemplates.Funcs(sprig.FuncMap()) for _, pattern := range ns.Cfg.Smtp.TemplatesPatterns { templatePattern := filepath.Join(ns.Cfg.StaticRootPath, pattern) diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 357b86c6c82..e14d60369f2 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -402,7 +402,7 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { t.Run("Removing user from org should delete user completely if in no other org", func(t *testing.T) { // make sure ac2 has no org - err := ss.DeleteOrg(context.Background(), &models.DeleteOrgCommand{Id: ac2.OrgID}) + err := orgUserStore.Delete(context.Background(), &org.DeleteOrgCommand{ID: ac2.OrgID}) require.NoError(t, err) // remove ac2 user from ac1 org diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go index 43d8c759aa7..9bb6a2ef22f 100644 --- a/pkg/services/preference/model.go +++ b/pkg/services/preference/model.go @@ -20,7 +20,7 @@ type Preference struct { Version int `db:"version"` HomeDashboardID int64 `xorm:"home_dashboard_id" db:"home_dashboard_id"` Timezone string `db:"timezone"` - WeekStart string `db:"week_start"` + WeekStart *string `db:"week_start"` Theme string `db:"theme"` Created time.Time `db:"created"` Updated time.Time `db:"updated"` @@ -49,7 +49,7 @@ type SavePreferenceCommand struct { Timezone string `json:"timezone,omitempty"` WeekStart string `json:"weekStart,omitempty"` Theme string `json:"theme,omitempty"` - Locale string `json:"locale,omitempty"` + Language string `json:"language,omitempty"` Navbar *NavbarPreference `json:"navbar,omitempty"` QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } @@ -64,7 +64,7 @@ type PatchPreferenceCommand struct { Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,omitempty"` Theme *string `json:"theme,omitempty"` - Locale *string `json:"locale,omitempty"` + Language *string `json:"language,omitempty"` Navbar *NavbarPreference `json:"navbar,omitempty"` QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } @@ -81,7 +81,7 @@ type NavbarPreference struct { } type PreferenceJSONData struct { - Locale string `json:"locale"` + Language string `json:"language"` Navbar NavbarPreference `json:"navbar"` QueryHistory QueryHistoryPreference `json:"queryHistory"` } diff --git a/pkg/services/preference/prefimpl/pref.go b/pkg/services/preference/prefimpl/pref.go index 35839b643a2..feadcbfa06e 100644 --- a/pkg/services/preference/prefimpl/pref.go +++ b/pkg/services/preference/prefimpl/pref.go @@ -54,15 +54,15 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference if p.Timezone != "" { res.Timezone = p.Timezone } - if p.WeekStart != "" { + if p.WeekStart != nil && *p.WeekStart != "" { res.WeekStart = p.WeekStart } if p.HomeDashboardID != 0 { res.HomeDashboardID = p.HomeDashboardID } if p.JSONData != nil { - if p.JSONData.Locale != "" { - res.JSONData.Locale = p.JSONData.Locale + if p.JSONData.Language != "" { + res.JSONData.Language = p.JSONData.Language } if len(p.JSONData.Navbar.SavedItems) > 0 { @@ -108,12 +108,12 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err TeamID: cmd.TeamID, HomeDashboardID: cmd.HomeDashboardID, Timezone: cmd.Timezone, - WeekStart: cmd.WeekStart, + WeekStart: &cmd.WeekStart, Theme: cmd.Theme, Created: time.Now(), Updated: time.Now(), JSONData: &pref.PreferenceJSONData{ - Locale: cmd.Locale, + Language: cmd.Language, }, } _, err = s.store.Insert(ctx, preference) @@ -125,13 +125,13 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err } preference.Timezone = cmd.Timezone - preference.WeekStart = cmd.WeekStart + preference.WeekStart = &cmd.WeekStart preference.Theme = cmd.Theme preference.Updated = time.Now() preference.Version += 1 preference.HomeDashboardID = cmd.HomeDashboardID preference.JSONData = &pref.PreferenceJSONData{ - Locale: cmd.Locale, + Language: cmd.Language, } if cmd.Navbar != nil { @@ -166,11 +166,11 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e exists = true } - if cmd.Locale != nil { + if cmd.Language != nil { if preference.JSONData == nil { preference.JSONData = &pref.PreferenceJSONData{} } - preference.JSONData.Locale = *cmd.Locale + preference.JSONData.Language = *cmd.Language } if cmd.Navbar != nil { @@ -200,7 +200,7 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e } if cmd.WeekStart != nil { - preference.WeekStart = *cmd.WeekStart + preference.WeekStart = cmd.WeekStart } if cmd.Theme != nil { @@ -232,13 +232,13 @@ func (s *Service) GetDefaults() *pref.Preference { defaults := &pref.Preference{ Theme: s.cfg.DefaultTheme, Timezone: s.cfg.DateFormats.DefaultTimezone, - WeekStart: s.cfg.DateFormats.DefaultWeekStart, + WeekStart: &s.cfg.DateFormats.DefaultWeekStart, HomeDashboardID: 0, JSONData: &pref.PreferenceJSONData{}, } if s.features.IsEnabled(featuremgmt.FlagInternationalization) { - defaults.JSONData.Locale = s.cfg.DefaultLocale + defaults.JSONData.Language = s.cfg.DefaultLanguage } return defaults diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go index 28749e715bc..0f5868b0117 100644 --- a/pkg/services/preference/prefimpl/pref_test.go +++ b/pkg/services/preference/prefimpl/pref_test.go @@ -33,13 +33,15 @@ func TestGetDefaults(t *testing.T) { cfg: setting.NewCfg(), features: featuremgmt.WithFeatures(), } - prefService.cfg.DefaultLocale = "en-US" + prefService.cfg.DefaultLanguage = "en-US" prefService.cfg.DefaultTheme = "light" prefService.cfg.DateFormats.DefaultTimezone = "UTC" + weekStart := "" t.Run("GetDefaults", func(t *testing.T) { preference := prefService.GetDefaults() expected := &pref.Preference{ + WeekStart: &weekStart, Theme: "light", Timezone: "UTC", HomeDashboardID: 0, @@ -55,6 +57,7 @@ func TestGetDefaults(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) expected := &pref.Preference{ + WeekStart: &weekStart, Theme: "light", Timezone: "UTC", HomeDashboardID: 0, @@ -72,18 +75,20 @@ func TestGetDefaultsWithI18nFeatureFlag(t *testing.T) { cfg: setting.NewCfg(), features: featuremgmt.WithFeatures(featuremgmt.FlagInternationalization), } - prefService.cfg.DefaultLocale = "en-US" + weekStart := "" + prefService.cfg.DefaultLanguage = "en-US" prefService.cfg.DefaultTheme = "light" prefService.cfg.DateFormats.DefaultTimezone = "UTC" t.Run("GetDefaults", func(t *testing.T) { preference := prefService.GetDefaults() expected := &pref.Preference{ + WeekStart: &weekStart, Theme: "light", Timezone: "UTC", HomeDashboardID: 0, JSONData: &pref.PreferenceJSONData{ - Locale: "en-US", + Language: "en-US", }, } if diff := cmp.Diff(expected, preference); diff != "" { @@ -98,17 +103,19 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { cfg: setting.NewCfg(), features: featuremgmt.WithFeatures(), } - prefService.cfg.DefaultLocale = "en-US" + prefService.cfg.DefaultLanguage = "en-US" + weekStartOne := "1" + weekStartTwo := "2" insertPrefs(t, prefService.store, pref.Preference{ OrgID: 1, HomeDashboardID: 1, Theme: "dark", Timezone: "UTC", - WeekStart: "1", + WeekStart: &weekStartOne, JSONData: &pref.PreferenceJSONData{ - Locale: "en-GB", + Language: "en-GB", }, }, pref.Preference{ @@ -117,9 +124,9 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { HomeDashboardID: 4, Theme: "light", Timezone: "browser", - WeekStart: "2", + WeekStart: &weekStartTwo, JSONData: &pref.PreferenceJSONData{ - Locale: "en-AU", + Language: "en-AU", }, }, ) @@ -131,10 +138,10 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { expected := &pref.Preference{ Theme: "light", Timezone: "browser", - WeekStart: "2", + WeekStart: &weekStartTwo, HomeDashboardID: 4, JSONData: &pref.PreferenceJSONData{ - Locale: "en-AU", + Language: "en-AU", }, } if diff := cmp.Diff(expected, preference); diff != "" { @@ -150,10 +157,10 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { expected := &pref.Preference{ Theme: "dark", Timezone: "UTC", - WeekStart: "1", + WeekStart: &weekStartOne, HomeDashboardID: 1, JSONData: &pref.PreferenceJSONData{ - Locale: "en-GB", + Language: "en-GB", }, } if diff := cmp.Diff(expected, preference); diff != "" { @@ -163,6 +170,7 @@ func TestGetWithDefaults_withUserAndOrgPrefs(t *testing.T) { } func TestGetDefaults_JSONData(t *testing.T) { + weekStart := "" queryPreference := pref.QueryHistoryPreference{ HomeTab: "hometab", } @@ -201,9 +209,9 @@ func TestGetDefaults_JSONData(t *testing.T) { orgPreferencesJsonData := pref.PreferenceJSONData{ Navbar: orgNavbarPreferences, } - orgPreferencesWithLocaleJsonData := pref.PreferenceJSONData{ - Navbar: orgNavbarPreferences, - Locale: "en-GB", + orgPreferencesWithLanguageJsonData := pref.PreferenceJSONData{ + Navbar: orgNavbarPreferences, + Language: "en-GB", } team2PreferencesJsonData := pref.PreferenceJSONData{ Navbar: team2NavbarPreferences, @@ -235,11 +243,12 @@ func TestGetDefaults_JSONData(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) require.Equal(t, &pref.Preference{ - JSONData: &userPreferencesJsonData, + WeekStart: &weekStart, + JSONData: &userPreferencesJsonData, }, preference) }) - t.Run("user JSONData with missing locale does not override org preference", func(t *testing.T) { + t.Run("user JSONData with missing language does not override org preference", func(t *testing.T) { prefService := &Service{ store: newFake(), cfg: setting.NewCfg(), @@ -249,7 +258,7 @@ func TestGetDefaults_JSONData(t *testing.T) { insertPrefs(t, prefService.store, pref.Preference{ OrgID: 1, - JSONData: &orgPreferencesWithLocaleJsonData, + JSONData: &orgPreferencesWithLanguageJsonData, }, pref.Preference{ OrgID: 1, @@ -262,8 +271,9 @@ func TestGetDefaults_JSONData(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) require.Equal(t, &pref.Preference{ + WeekStart: &weekStart, JSONData: &pref.PreferenceJSONData{ - Locale: "en-GB", + Language: "en-GB", Navbar: userNavbarPreferences, QueryHistory: queryPreference, }, @@ -300,12 +310,15 @@ func TestGetDefaults_JSONData(t *testing.T) { preference, err := prefService.GetWithDefaults(context.Background(), query) require.NoError(t, err) require.Equal(t, &pref.Preference{ - JSONData: &team2PreferencesJsonData, + JSONData: &team2PreferencesJsonData, + WeekStart: &weekStart, }, preference) }) } func TestGetWithDefaults_teams(t *testing.T) { + weekStartOne := "1" + weekStartTwo := "2" prefService := &Service{ store: newFake(), cfg: setting.NewCfg(), @@ -317,7 +330,7 @@ func TestGetWithDefaults_teams(t *testing.T) { HomeDashboardID: 1, Theme: "light", Timezone: "browser", - WeekStart: "1", + WeekStart: &weekStartOne, }, pref.Preference{ OrgID: 1, @@ -325,7 +338,7 @@ func TestGetWithDefaults_teams(t *testing.T) { HomeDashboardID: 3, Theme: "light", Timezone: "browser", - WeekStart: "2", + WeekStart: &weekStartTwo, }, pref.Preference{ OrgID: 1, @@ -333,7 +346,7 @@ func TestGetWithDefaults_teams(t *testing.T) { HomeDashboardID: 4, Theme: "light", Timezone: "browser", - WeekStart: "2", + WeekStart: &weekStartTwo, }, ) @@ -343,7 +356,7 @@ func TestGetWithDefaults_teams(t *testing.T) { expected := &pref.Preference{ Theme: "light", Timezone: "browser", - WeekStart: "2", + WeekStart: &weekStartTwo, HomeDashboardID: 4, JSONData: &pref.PreferenceJSONData{}, } @@ -399,7 +412,7 @@ func TestSave(t *testing.T) { assert.Equal(t, "dark", stored.Theme) assert.Equal(t, "browser", stored.Timezone) assert.EqualValues(t, 5, stored.HomeDashboardID) - assert.Equal(t, "1", stored.WeekStart) + assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 0, stored.Version) }) @@ -421,7 +434,7 @@ func TestSave(t *testing.T) { assert.Empty(t, stored.Theme) assert.Equal(t, "UTC", stored.Timezone) assert.Zero(t, stored.HomeDashboardID) - assert.Equal(t, "1", stored.WeekStart) + assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 1, stored.Version) }) @@ -440,7 +453,7 @@ func TestSave(t *testing.T) { assert.Equal(t, themeValue, stored.Theme) assert.Equal(t, "UTC", stored.Timezone) assert.Zero(t, stored.HomeDashboardID) - assert.Equal(t, "1", stored.WeekStart) + assert.Equal(t, "1", *stored.WeekStart) assert.EqualValues(t, 2, stored.Version) }) } diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 5aaf283e32f..0773c1c7134 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -17,7 +17,7 @@ type getStore func(db.DB) store func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { t.Helper() - + weekStartOne := "1" ss := db.InitTestDB(t) prefStore := fn(ss) orgNavbarPreferences := pref.NavbarPreference{ @@ -123,7 +123,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { Theme: "dark", Timezone: "browser", HomeDashboardID: 5, - WeekStart: "1", + WeekStart: &weekStartOne, JSONData: &pref.PreferenceJSONData{Navbar: orgNavbarPreferences}, Created: time.Now(), Updated: time.Now(), @@ -135,7 +135,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { Theme: "dark", HomeDashboardID: 5, Timezone: "browser", - WeekStart: "1", + WeekStart: &weekStartOne, Created: time.Now(), Updated: time.Now(), JSONData: &pref.PreferenceJSONData{}, @@ -149,7 +149,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { Version: prefs[0].Version, HomeDashboardID: 5, Timezone: "browser", - WeekStart: "1", + WeekStart: &weekStartOne, Theme: "dark", JSONData: prefs[0].JSONData, Created: prefs[0].Created, diff --git a/pkg/services/provisioning/alerting/notification_policy_types.go b/pkg/services/provisioning/alerting/notification_policy_types.go index 21987888612..dae145211ad 100644 --- a/pkg/services/provisioning/alerting/notification_policy_types.go +++ b/pkg/services/provisioning/alerting/notification_policy_types.go @@ -1,26 +1,54 @@ package alerting import ( + "encoding/json" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/provisioning/values" ) type NotificiationPolicyV1 struct { - OrgID values.Int64Value `json:"orgId" yaml:"orgId"` - Policy definitions.Route `json:",inline" yaml:",inline"` + OrgID values.Int64Value `json:"orgId" yaml:"orgId"` + // We use JSONValue here, as we want to have interpolation the values. + Policy values.JSONValue `json:"-" yaml:"-"` } -func (v1 *NotificiationPolicyV1) mapToModel() NotificiationPolicy { +func (v1 *NotificiationPolicyV1) UnmarshalYAML(unmarshal func(interface{}) error) error { + err := v1.Policy.UnmarshalYAML(unmarshal) + if err != nil { + return err + } + // As we also want to unmarshal the orgId and any other field that might be + // added in the future we create an alias type that prevents recursion + // and just uses the default marshler. + type plain NotificiationPolicyV1 + return unmarshal((*plain)(v1)) +} + +func (v1 *NotificiationPolicyV1) mapToModel() (NotificiationPolicy, error) { orgID := v1.OrgID.Value() if orgID < 1 { orgID = 1 } - // we don't need any further validation here as it's done by - // the notification policy service + var route definitions.Route + // We need the string json representation, so we marshal the policy back + // as a string and interpolate it at the same time. + data, err := json.Marshal(v1.Policy.Value()) + if err != nil { + return NotificiationPolicy{}, err + } + // Now we can take the interpolated string json represtenation of the policy + // and unmarshal it in the concrete type. + err = json.Unmarshal(data, &route) + if err != nil { + return NotificiationPolicy{}, err + } + // We don't need any further validation here as it's done by + // the notification policy service. return NotificiationPolicy{ OrgID: orgID, - Policy: v1.Policy, - } + Policy: route, + }, nil } type NotificiationPolicy struct { diff --git a/pkg/services/provisioning/alerting/notification_policy_types_test.go b/pkg/services/provisioning/alerting/notification_policy_types_test.go new file mode 100644 index 00000000000..3538ad23657 --- /dev/null +++ b/pkg/services/provisioning/alerting/notification_policy_types_test.go @@ -0,0 +1,37 @@ +package alerting + +import ( + "os" + "testing" + + "gopkg.in/yaml.v2" + + "github.com/stretchr/testify/require" +) + +func TestNotificationPolicy(t *testing.T) { + const ( + envKey = "NOTIFIER_EMAIL_REMINDER_FREQUENCY" + envValue = "4h" + ) + err := os.Setenv(envKey, envValue) + require.NoError(t, err) + defer func() { + _ = os.Unsetenv(envKey) + }() + data := `orgId: 123 +receiver: test +continue: true +repeat_interval: ${NOTIFIER_EMAIL_REMINDER_FREQUENCY} +` + var model NotificiationPolicyV1 + + err = yaml.Unmarshal([]byte(data), &model) + require.NoError(t, err) + np, err := model.mapToModel() + require.NoError(t, err) + require.Equal(t, int64(123), np.OrgID) + require.Equal(t, "test", np.Policy.Receiver) + require.True(t, np.Policy.Continue) + require.Equal(t, envValue, np.Policy.RepeatInterval.String()) +} diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index c74b9a2da1b..b300d918c46 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/provisioning/values" + + "github.com/prometheus/common/model" ) type RuleDelete struct { @@ -39,11 +41,11 @@ func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (AlertRuleGroup, error) { if ruleGroup.OrgID < 1 { ruleGroup.OrgID = 1 } - interval, err := time.ParseDuration(ruleGroupV1.Interval.Value()) + interval, err := model.ParseDuration(ruleGroupV1.Interval.Value()) if err != nil { return AlertRuleGroup{}, err } - ruleGroup.Interval = interval + ruleGroup.Interval = time.Duration(interval) ruleGroup.Folder = ruleGroupV1.Folder.Value() if strings.TrimSpace(ruleGroup.Folder) == "" { return AlertRuleGroup{}, errors.New("rule group has no folder set") @@ -91,11 +93,11 @@ func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: no UID set", alertRule.Title) } alertRule.OrgID = orgID - duration, err := time.ParseDuration(rule.For.Value()) + duration, err := model.ParseDuration(rule.For.Value()) if err != nil { return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) } - alertRule.For = duration + alertRule.For = time.Duration(duration) dashboardUID := rule.DashboardUID.Value() alertRule.DashboardUID = &dashboardUID panelID := rule.PanelID.Value() diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index 877d4f84b14..0eac93e2404 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -2,6 +2,7 @@ package alerting import ( "testing" + "time" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/provisioning/values" @@ -51,6 +52,16 @@ func TestRuleGroup(t *testing.T) { _, err = rg.MapToModel() require.Error(t, err) }) + t.Run("a rule group with an interval containing 'd' should work", func(t *testing.T) { + rg := validRuleGroupV1(t) + var interval values.StringValue + err := yaml.Unmarshal([]byte("2d"), &interval) + require.NoError(t, err) + rg.Interval = interval + rgMapped, err := rg.MapToModel() + require.NoError(t, err) + require.Equal(t, 48*time.Hour, rgMapped.Interval) + }) t.Run("a rule group with an empty org id should default to 1", func(t *testing.T) { rg := validRuleGroupV1(t) rg.OrgID = values.Int64Value{} @@ -103,6 +114,16 @@ func TestRules(t *testing.T) { _, err = rule.mapToModel(1) require.Error(t, err) }) + t.Run("a rule with a for duration containing 'd' should work", func(t *testing.T) { + rule := validRuleV1(t) + forDuration := values.StringValue{} + err := yaml.Unmarshal([]byte("2d"), &forDuration) + rule.For = forDuration + require.NoError(t, err) + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Equal(t, 48*time.Hour, ruleMapped.For) + }) t.Run("a rule with out a condition should error", func(t *testing.T) { rule := validRuleV1(t) rule.Condition = values.StringValue{} diff --git a/pkg/services/provisioning/alerting/types.go b/pkg/services/provisioning/alerting/types.go index 7efe34b4025..6b500c85dd1 100644 --- a/pkg/services/provisioning/alerting/types.go +++ b/pkg/services/provisioning/alerting/types.go @@ -51,7 +51,9 @@ func (fileV1 *AlertingFileV1) MapToModel() (AlertingFile, error) { if err := fileV1.mapContactPoint(&alertingFile); err != nil { return AlertingFile{}, fmt.Errorf("failure parsing contact points: %w", err) } - fileV1.mapPolicies(&alertingFile) + if err := fileV1.mapPolicies(&alertingFile); err != nil { + return AlertingFile{}, fmt.Errorf("failure parsing policies: %w", err) + } if err := fileV1.mapMuteTimes(&alertingFile); err != nil { return AlertingFile{}, fmt.Errorf("failure parsing mute times: %w", err) } @@ -89,13 +91,18 @@ func (fileV1 *AlertingFileV1) mapMuteTimes(alertingFile *AlertingFile) error { return nil } -func (fileV1 *AlertingFileV1) mapPolicies(alertingFile *AlertingFile) { +func (fileV1 *AlertingFileV1) mapPolicies(alertingFile *AlertingFile) error { for _, npV1 := range fileV1.Policies { - alertingFile.Policies = append(alertingFile.Policies, npV1.mapToModel()) + np, err := npV1.mapToModel() + if err != nil { + return err + } + alertingFile.Policies = append(alertingFile.Policies, np) } for _, orgIDV1 := range fileV1.ResetPolicies { alertingFile.ResetPolicies = append(alertingFile.ResetPolicies, OrgID(orgIDV1.Value())) } + return nil } func (fileV1 *AlertingFileV1) mapContactPoint(alertingFile *AlertingFile) error { diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index b565085dceb..8a4c200d5f8 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -74,7 +74,7 @@ func createDashboardJSON(data *simplejson.Json, lastModified time.Time, cfg *con } func mapV0ToDashboardsAsConfig(v0 []*configV0) ([]*config, error) { - var r []*config + r := make([]*config, 0, len(v0)) seen := make(map[string]bool) for _, v := range v0 { @@ -101,7 +101,7 @@ func mapV0ToDashboardsAsConfig(v0 []*configV0) ([]*config, error) { } func (dc *configV1) mapToDashboardsAsConfig() ([]*config, error) { - var r []*config + r := make([]*config, 0, len(dc.Providers)) seen := make(map[string]bool) for _, v := range dc.Providers { diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 773d0577f43..cefd648e19e 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -372,7 +372,8 @@ func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUse req.httpRequest = reqDTO.HTTPRequest } - return req, req.validateRequest() + _ = req.validateRequest() + return req, nil // TODO req.validateRequest() } func (s *Service) getDataSourceFromQuery(ctx context.Context, user *user.SignedInUser, skipCache bool, query *simplejson.Json, history map[string]*datasources.DataSource) (*datasources.DataSource, error) { diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index e8da4ab6481..af81ea8f8b4 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -189,7 +189,7 @@ func TestParseMetricRequest(t *testing.T) { httpreq.Header.Add("X-Datasource-Uid", "gIEkMvIVz") mr.HTTPRequest = httpreq _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) - require.Error(t, err) + require.NoError(t, err) // With the second value it is OK httpreq.Header.Add("X-Datasource-Uid", "sEx6ZvSVk") diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 17164adc785..6614bcbdaf9 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/dashboards" dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/datasources" @@ -464,7 +465,7 @@ func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quot func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) require.NoError(t, err) - _, err = auth.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) + _, err = authimpl.ProvideUserAuthTokenService(sqlStore, sqlStore.Cfg, nil, quotaService) require.NoError(t, err) _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) require.NoError(t, err) diff --git a/pkg/services/screenshot/option.go b/pkg/services/screenshot/option.go index e84d2ed3a55..f19381abdae 100644 --- a/pkg/services/screenshot/option.go +++ b/pkg/services/screenshot/option.go @@ -9,16 +9,20 @@ import ( ) var ( - DefaultTheme = models.ThemeDark - DefaultTimeout = 15 * time.Second + DefaultFrom = "now-1h" + DefaultTo = "now" DefaultHeight = 500 DefaultWidth = 1000 + DefaultTheme = models.ThemeDark + DefaultTimeout = 15 * time.Second ) // ScreenshotOptions are the options for taking a screenshot. type ScreenshotOptions struct { DashboardUID string PanelID int64 + From string + To string Width int Height int Theme models.Theme @@ -27,6 +31,12 @@ type ScreenshotOptions struct { // SetDefaults sets default values for missing or invalid options. func (s ScreenshotOptions) SetDefaults() ScreenshotOptions { + if s.From == "" { + s.From = DefaultFrom + } + if s.To == "" { + s.To = DefaultTo + } if s.Width <= 0 { s.Width = DefaultWidth } @@ -48,6 +58,8 @@ func (s ScreenshotOptions) Hash() []byte { h := fnv.New64() _, _ = h.Write([]byte(s.DashboardUID)) _, _ = h.Write([]byte(strconv.FormatInt(s.PanelID, 10))) + _, _ = h.Write([]byte(s.From)) + _, _ = h.Write([]byte(s.To)) _, _ = h.Write([]byte(strconv.FormatInt(int64(s.Width), 10))) _, _ = h.Write([]byte(strconv.FormatInt(int64(s.Height), 10))) _, _ = h.Write([]byte(s.Theme)) diff --git a/pkg/services/screenshot/option_test.go b/pkg/services/screenshot/option_test.go index 50552c1431b..2187b032391 100644 --- a/pkg/services/screenshot/option_test.go +++ b/pkg/services/screenshot/option_test.go @@ -12,6 +12,30 @@ func TestScreenshotOptions(t *testing.T) { o = o.SetDefaults() assert.Equal(t, ScreenshotOptions{ + From: DefaultFrom, + To: DefaultTo, + Width: DefaultWidth, + Height: DefaultHeight, + Theme: DefaultTheme, + Timeout: DefaultTimeout, + }, o) + + o.From = "now-6h" + o = o.SetDefaults() + assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: DefaultTo, + Width: DefaultWidth, + Height: DefaultHeight, + Theme: DefaultTheme, + Timeout: DefaultTimeout, + }, o) + + o.To = "now-2h" + o = o.SetDefaults() + assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: "now-2h", Width: DefaultWidth, Height: DefaultHeight, Theme: DefaultTheme, @@ -21,6 +45,8 @@ func TestScreenshotOptions(t *testing.T) { o.Width = 100 o = o.SetDefaults() assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: "now-2h", Width: 100, Height: DefaultHeight, Theme: DefaultTheme, @@ -30,6 +56,8 @@ func TestScreenshotOptions(t *testing.T) { o.Height = 100 o = o.SetDefaults() assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: "now-2h", Width: 100, Height: 100, Theme: DefaultTheme, @@ -39,6 +67,8 @@ func TestScreenshotOptions(t *testing.T) { o.Theme = "Not a theme" o = o.SetDefaults() assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: "now-2h", Width: 100, Height: 100, Theme: DefaultTheme, @@ -47,6 +77,8 @@ func TestScreenshotOptions(t *testing.T) { o.Timeout = DefaultTimeout + 1 assert.Equal(t, ScreenshotOptions{ + From: "now-6h", + To: "now-2h", Width: 100, Height: 100, Theme: DefaultTheme, @@ -59,21 +91,27 @@ func TestScreenshotOptions_Hash(t *testing.T) { assert.Equal(t, []byte{0xd9, 0x83, 0x82, 0x18, 0x6c, 0x3d, 0x7d, 0x47}, o.Hash()) o = o.SetDefaults() - assert.Equal(t, []byte{0x6, 0x7, 0x97, 0x6, 0x53, 0xf, 0x8b, 0xf1}, o.Hash()) + assert.Equal(t, []byte{0x3, 0x52, 0x33, 0x5f, 0xed, 0x96, 0x47, 0xb5}, o.Hash()) + + o.From = "now-6h" + assert.Equal(t, []byte{0x1b, 0x61, 0xc4, 0x40, 0x1f, 0xae, 0xa0, 0xe0}, o.Hash()) + + o.To = "now-2h" + assert.Equal(t, []byte{0x2, 0x4f, 0xfd, 0xd5, 0x68, 0xdd, 0xd1, 0x99}, o.Hash()) o.Width = 100 o = o.SetDefaults() - assert.Equal(t, []byte{0x25, 0x50, 0xb4, 0x4b, 0x43, 0xcd, 0x3, 0x49}, o.Hash()) + assert.Equal(t, []byte{0x16, 0x6c, 0xd, 0xad, 0x9f, 0x32, 0x9d, 0x1}, o.Hash()) o.Height = 100 o = o.SetDefaults() - assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) + assert.Equal(t, []byte{0x2, 0x82, 0x96, 0x68, 0x2b, 0x57, 0x7d, 0xdd}, o.Hash()) o.Theme = "Not a theme" o = o.SetDefaults() - assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) + assert.Equal(t, []byte{0x2, 0x82, 0x96, 0x68, 0x2b, 0x57, 0x7d, 0xdd}, o.Hash()) // the timeout should not change the sum o.Timeout = DefaultTimeout + 1 - assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) + assert.Equal(t, []byte{0x2, 0x82, 0x96, 0x68, 0x2b, 0x57, 0x7d, 0xdd}, o.Hash()) } diff --git a/pkg/services/screenshot/screenshot.go b/pkg/services/screenshot/screenshot.go index 468e3202d35..e8d6b41b695 100644 --- a/pkg/services/screenshot/screenshot.go +++ b/pkg/services/screenshot/screenshot.go @@ -92,14 +92,17 @@ func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOpt return nil, err } + opts = opts.SetDefaults() + u := url.URL{} u.Path = path.Join("d-solo", q.Result.Uid, q.Result.Slug) p := u.Query() p.Add("orgId", strconv.FormatInt(q.Result.OrgId, 10)) p.Add("panelId", strconv.FormatInt(opts.PanelID, 10)) + p.Add("from", opts.From) + p.Add("to", opts.To) u.RawQuery = p.Encode() - opts = opts.SetDefaults() renderOpts := rendering.Opts{ AuthOpts: rendering.AuthOpts{ OrgID: q.Result.OrgId, diff --git a/pkg/services/screenshot/screenshot_test.go b/pkg/services/screenshot/screenshot_test.go index caeea6a98b2..d4382bbd960 100644 --- a/pkg/services/screenshot/screenshot_test.go +++ b/pkg/services/screenshot/screenshot_test.go @@ -34,6 +34,7 @@ func TestHeadlessScreenshotService(t *testing.T) { assert.EqualError(t, err, "Dashboard not found") assert.Nil(t, screenshot) + // should take a screenshot d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*models.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*models.GetDashboardQuery) q.Result = &models.Dashboard{Id: 1, Uid: "foo", Slug: "bar", OrgId: 2} @@ -54,10 +55,12 @@ func TestHeadlessScreenshotService(t *testing.T) { Width: DefaultWidth, Height: DefaultHeight, Theme: DefaultTheme, - Path: "d-solo/foo/bar?orgId=2&panelId=4", + Path: "d-solo/foo/bar?from=now-6h&orgId=2&panelId=4&to=now-2h", ConcurrentLimit: setting.AlertingRenderLimit, } + opts.From = "now-6h" + opts.To = "now-2h" opts.DashboardUID = "foo" opts.PanelID = 4 r.EXPECT(). diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index c1f31191dbd..2327422c7eb 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -160,6 +160,14 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon } } + if api.cfg.SATokenExpirationDayLimit > 0 { + dayExpireLimit := time.Now().Add(time.Duration(api.cfg.SATokenExpirationDayLimit) * time.Hour * 24).Truncate(24 * time.Hour) + expirationDate := time.Now().Add(time.Duration(cmd.SecondsToLive) * time.Second).Truncate(24 * time.Hour) + if expirationDate.After(dayExpireLimit) { + return response.Respond(http.StatusBadRequest, "The expiration date input exceeds the limit for service account access tokens expiration date") + } + } + newKeyInfo, err := apikeygenprefix.New(ServiceID) if err != nil { return response.Error(http.StatusInternalServerError, "Generating service account token failed", err) diff --git a/pkg/services/sqlstore/migrations/dashboard_public_mig.go b/pkg/services/sqlstore/migrations/dashboard_public_mig.go index 1dfc3512018..abbdd136751 100644 --- a/pkg/services/sqlstore/migrations/dashboard_public_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_public_mig.go @@ -74,4 +74,7 @@ func addPublicDashboardMigration(mg *Migrator) { Nullable: false, Default: "0", })) + + mg.AddMigration("delete orphaned public dashboards", NewRawSQLMigration( + "DELETE FROM dashboard_public WHERE dashboard_uid NOT IN (SELECT uid FROM dashboard)")) } diff --git a/pkg/services/sqlstore/migrations/folder_mig.go b/pkg/services/sqlstore/migrations/folder_mig.go index 71938ed791b..4ef08d198e8 100644 --- a/pkg/services/sqlstore/migrations/folder_mig.go +++ b/pkg/services/sqlstore/migrations/folder_mig.go @@ -33,7 +33,7 @@ func folderv1() migrator.Table { {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, {Name: "title", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "description", Type: migrator.DB_NVarchar, Length: 255, Nullable: true}, - {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Default: ""}, + {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Nullable: true}, {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, }, diff --git a/pkg/services/sqlstore/migrations/ualert/channel.go b/pkg/services/sqlstore/migrations/ualert/channel.go index e6ce68cb93f..01981b47a5d 100644 --- a/pkg/services/sqlstore/migrations/ualert/channel.go +++ b/pkg/services/sqlstore/migrations/ualert/channel.go @@ -212,7 +212,7 @@ func (m *migration) createNotifier(c *notificationChannel) (*PostableGrafanaRece // Create one receiver for every unique notification channel. func (m *migration) createReceivers(allChannels []*notificationChannel) (map[uidOrID]*PostableApiReceiver, []*PostableApiReceiver, error) { - var receivers []*PostableApiReceiver + receivers := make([]*PostableApiReceiver, 0, len(allChannels)) receiversMap := make(map[uidOrID]*PostableApiReceiver) set := make(map[string]struct{}) // Used to deduplicate sanitized names. diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 1b4757b2e93..9ce5d0990ea 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -17,8 +17,7 @@ type OrgListResponse []struct { Response error } type SQLStoreMock struct { - LastGetAlertsQuery *models.GetAlertsQuery - LastLoginAttemptCommand *models.CreateLoginAttemptCommand + LastGetAlertsQuery *models.GetAlertsQuery ExpectedUser *user.User ExpectedTeamsByUser []*models.TeamDTO @@ -28,7 +27,6 @@ type SQLStoreMock struct { ExpectedDataSourcesAccessStats []*models.DataSourceAccessStats ExpectedNotifierUsageStats []*models.NotifierUsageStats ExpectedSignedInUser *user.SignedInUser - ExpectedLoginAttempts int64 ExpectedError error } @@ -130,11 +128,6 @@ func (m *SQLStoreMock) GetSqlxSession() *session.SessionDB { return nil } -func (m *SQLStoreMock) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { - m.LastLoginAttemptCommand = cmd - return m.ExpectedError -} - func (m *SQLStoreMock) GetAlertById(ctx context.Context, query *models.GetAlertByIdQuery) error { query.Result = m.ExpectedAlert return m.ExpectedError @@ -144,19 +137,10 @@ func (m *SQLStoreMock) GetAlertNotificationUidWithId(ctx context.Context, query return m.ExpectedError } -func (m *SQLStoreMock) DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error { - return m.ExpectedError -} - func (m *SQLStoreMock) GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) error { return m.ExpectedError } -func (m *SQLStoreMock) GetUserLoginAttemptCount(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error { - query.Result = m.ExpectedLoginAttempts - return m.ExpectedError -} - func (m *SQLStoreMock) GetAlertStatesForDashboard(ctx context.Context, query *models.GetAlertStatesForDashboardQuery) error { return m.ExpectedError } diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 1231c2ea081..fdf4b1bae97 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -71,11 +71,6 @@ func (ss *SQLStore) createOrg(ctx context.Context, name string, userID int64, en return orga, nil } -// CreateOrgWithMember creates an organization with a certain name and a certain user as member. -func (ss *SQLStore) CreateOrgWithMember(name string, userID int64) (models.Org, error) { - return ss.createOrg(context.Background(), name, userID, ss.engine) -} - func (ss *SQLStore) CreateOrg(ctx context.Context, cmd *models.CreateOrgCommand) error { org, err := ss.createOrg(ctx, cmd.Name, cmd.UserId, ss.engine) if err != nil { @@ -86,74 +81,6 @@ func (ss *SQLStore) CreateOrg(ctx context.Context, cmd *models.CreateOrgCommand) return nil } -func (ss *SQLStore) UpdateOrgAddress(ctx context.Context, cmd *models.UpdateOrgAddressCommand) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - org := models.Org{ - Address1: cmd.Address1, - Address2: cmd.Address2, - City: cmd.City, - ZipCode: cmd.ZipCode, - State: cmd.State, - Country: cmd.Country, - - Updated: time.Now(), - } - - if _, err := sess.ID(cmd.OrgId).Update(&org); err != nil { - return err - } - - sess.publishAfterCommit(&events.OrgUpdated{ - Timestamp: org.Updated, - Id: org.Id, - Name: org.Name, - }) - - return nil - }) -} - -func (ss *SQLStore) DeleteOrg(ctx context.Context, cmd *models.DeleteOrgCommand) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - if res, err := sess.Query("SELECT 1 from org WHERE id=?", cmd.Id); err != nil { - return err - } else if len(res) != 1 { - return models.ErrOrgNotFound - } - - deletes := []string{ - "DELETE FROM star WHERE EXISTS (SELECT 1 FROM dashboard WHERE org_id = ? AND star.dashboard_id = dashboard.id)", - "DELETE FROM dashboard_tag WHERE EXISTS (SELECT 1 FROM dashboard WHERE org_id = ? AND dashboard_tag.dashboard_id = dashboard.id)", - "DELETE FROM dashboard WHERE org_id = ?", - "DELETE FROM api_key WHERE org_id = ?", - "DELETE FROM data_source WHERE org_id = ?", - "DELETE FROM org_user WHERE org_id = ?", - "DELETE FROM org WHERE id = ?", - "DELETE FROM temp_user WHERE org_id = ?", - "DELETE FROM ngalert_configuration WHERE org_id = ?", - "DELETE FROM alert_configuration WHERE org_id = ?", - "DELETE FROM alert_instance WHERE rule_org_id = ?", - "DELETE FROM alert_notification WHERE org_id = ?", - "DELETE FROM alert_notification_state WHERE org_id = ?", - "DELETE FROM alert_rule WHERE org_id = ?", - "DELETE FROM alert_rule_tag WHERE EXISTS (SELECT 1 FROM alert WHERE alert.org_id = ? AND alert.id = alert_rule_tag.alert_id)", - "DELETE FROM alert_rule_version WHERE rule_org_id = ?", - "DELETE FROM alert WHERE org_id = ?", - "DELETE FROM annotation WHERE org_id = ?", - "DELETE FROM kv_store WHERE org_id = ?", - } - - for _, sql := range deletes { - _, err := sess.Exec(sql, cmd.Id) - if err != nil { - return err - } - } - - return nil - }) -} - func verifyExistingOrg(sess *DBSession, orgId int64) error { var org models.Org has, err := sess.Where("id=?", orgId).Get(&org) diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 86186adfee9..4a085f55e7d 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -73,7 +73,7 @@ func TestIntegrationAccountDataAccess(t *testing.T) { Role: org.RoleViewer, } - err := sqlStore.AddOrgUser(context.Background(), &cmd) + err := sqlStore.addOrgUser(context.Background(), &cmd) t.Run("Should have been saved without error", func(t *testing.T) { require.NoError(t, err) }) @@ -142,7 +142,7 @@ func TestIntegrationAccountDataAccess(t *testing.T) { Role: org.RoleViewer, } - err = sqlStore.AddOrgUser(context.Background(), &orgUserCmd) + err = sqlStore.addOrgUser(context.Background(), &orgUserCmd) require.NoError(t, err) dash1 := insertTestDashboard(t, sqlStore, "1 test dash", ac1.OrgID, 0, false, "prod", "webapp") diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 34de541d591..9290a6c4670 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" ) -func (ss *SQLStore) AddOrgUser(ctx context.Context, cmd *models.AddOrgUserCommand) error { +func (ss *SQLStore) addOrgUser(ctx context.Context, cmd *models.AddOrgUserCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { // check if user exists var usr user.User diff --git a/pkg/services/sqlstore/org_users_test.go b/pkg/services/sqlstore/org_users_test.go index a2ca123f4f4..7528e679805 100644 --- a/pkg/services/sqlstore/org_users_test.go +++ b/pkg/services/sqlstore/org_users_test.go @@ -32,7 +32,7 @@ func TestSQLStore_AddOrgUser(t *testing.T) { require.Equal(t, int64(-1), sa.OrgID) // assign the sa to the org but without the override. should fail - err = store.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + err = store.addOrgUser(context.Background(), &models.AddOrgUserCommand{ Role: "Viewer", OrgId: orgID, UserId: sa.ID, @@ -40,7 +40,7 @@ func TestSQLStore_AddOrgUser(t *testing.T) { require.Error(t, err) // assign the sa to the org with the override. should succeed - err = store.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + err = store.addOrgUser(context.Background(), &models.AddOrgUserCommand{ Role: "Viewer", OrgId: orgID, UserId: sa.ID, diff --git a/pkg/services/sqlstore/session.go b/pkg/services/sqlstore/session.go index 973e6c63e67..8f9e2a63d37 100644 --- a/pkg/services/sqlstore/session.go +++ b/pkg/services/sqlstore/session.go @@ -9,10 +9,13 @@ import ( "xorm.io/xorm" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util/errutil" + "github.com/grafana/grafana/pkg/util/retryer" "github.com/mattn/go-sqlite3" ) var sessionLogger = log.New("sqlstore.session") +var ErrMaximumRetriesReached = errutil.NewBase(errutil.StatusInternal, "sqlstore.max-retries-reached") type DBSession struct { *xorm.Session @@ -68,23 +71,34 @@ func (ss *SQLStore) WithDbSession(ctx context.Context, callback DBTransactionFun func (ss *SQLStore) WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error { sess := &DBSession{Session: ss.engine.NewSession(), transactionOpen: false} defer sess.Close() - return ss.withRetry(ctx, callback, 0)(sess) + retry := 0 + return retryer.Retry(ss.retryOnLocks(ctx, callback, sess, retry), ss.dbCfg.QueryRetries, time.Millisecond*time.Duration(10), time.Second) } -func (ss *SQLStore) withRetry(ctx context.Context, callback DBTransactionFunc, retry int) DBTransactionFunc { - return func(sess *DBSession) error { +func (ss *SQLStore) retryOnLocks(ctx context.Context, callback DBTransactionFunc, sess *DBSession, retry int) func() (retryer.RetrySignal, error) { + return func() (retryer.RetrySignal, error) { + retry++ + err := callback(sess) ctxLogger := tsclogger.FromContext(ctx) var sqlError sqlite3.Error - if errors.As(err, &sqlError) && retry < ss.dbCfg.QueryRetries && (sqlError.Code == sqlite3.ErrLocked || sqlError.Code == sqlite3.ErrBusy) { - time.Sleep(time.Millisecond * time.Duration(10)) + if errors.As(err, &sqlError) && (sqlError.Code == sqlite3.ErrLocked || sqlError.Code == sqlite3.ErrBusy) { ctxLogger.Info("Database locked, sleeping then retrying", "error", err, "retry", retry, "code", sqlError.Code) - return ss.withRetry(ctx, callback, retry+1)(sess) + // retryer immediately returns the error (if there is one) without checking the response + // therefore we only have to send it if we have reached the maximum retries + if retry == ss.dbCfg.QueryRetries { + return retryer.FuncError, ErrMaximumRetriesReached.Errorf("retry %d: %w", retry, err) + } + return retryer.FuncFailure, nil } - return err + if err != nil { + return retryer.FuncError, err + } + + return retryer.FuncComplete, nil } } @@ -96,7 +110,8 @@ func (ss *SQLStore) withDbSession(ctx context.Context, engine *xorm.Engine, call if isNew { defer sess.Close() } - return ss.withRetry(ctx, callback, 0)(sess) + retry := 0 + return retryer.Retry(ss.retryOnLocks(ctx, callback, sess, retry), ss.dbCfg.QueryRetries, time.Millisecond*time.Duration(10), time.Second) } func (sess *DBSession) InsertId(bean interface{}) (int64, error) { diff --git a/pkg/services/sqlstore/session_test.go b/pkg/services/sqlstore/session_test.go index 6d22c735e5f..160bc280216 100644 --- a/pkg/services/sqlstore/session_test.go +++ b/pkg/services/sqlstore/session_test.go @@ -15,48 +15,51 @@ func TestRetryingOnFailures(t *testing.T) { store.dbCfg.QueryRetries = 5 funcToTest := map[string]func(ctx context.Context, callback DBTransactionFunc) error{ - "WithDbSession()": store.WithDbSession, - "WithNewDbSession()": store.WithNewDbSession, + "WithDbSession": store.WithDbSession, + "WithNewDbSession": store.WithNewDbSession, } for name, f := range funcToTest { - i := 0 t.Run(fmt.Sprintf("%s should return the error immediately if it's other than sqlite3.ErrLocked or sqlite3.ErrBusy", name), func(t *testing.T) { - err := f(context.Background(), func(sess *DBSession) error { + i := 0 + callback := func(sess *DBSession) error { i++ return errors.New("some error") - }) + } + err := f(context.Background(), callback) require.Error(t, err) - require.Equal(t, i, 1) + require.Equal(t, 1, i) }) - i = 0 t.Run(fmt.Sprintf("%s should return the sqlite3.Error if all retries have failed", name), func(t *testing.T) { - err := f(context.Background(), func(sess *DBSession) error { + i := 0 + callback := func(sess *DBSession) error { i++ return sqlite3.Error{Code: sqlite3.ErrBusy} - }) + } + err := f(context.Background(), callback) require.Error(t, err) var driverErr sqlite3.Error require.ErrorAs(t, err, &driverErr) - require.Equal(t, i, store.dbCfg.QueryRetries+1) + require.Equal(t, store.dbCfg.QueryRetries, i) }) - i = 0 t.Run(fmt.Sprintf("%s should not return the error if successive retries succeed", name), func(t *testing.T) { - err := f(context.Background(), func(sess *DBSession) error { + i := 0 + callback := func(sess *DBSession) error { + i++ var err error switch { - case i >= store.dbCfg.QueryRetries: + case store.dbCfg.QueryRetries == i: err = nil default: err = sqlite3.Error{Code: sqlite3.ErrBusy} } - i++ return err - }) + } + err := f(context.Background(), callback) require.NoError(t, err) - require.Equal(t, i, store.dbCfg.QueryRetries+1) + require.Equal(t, store.dbCfg.QueryRetries, i) }) } diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index a7c6e71506f..1b0eca543e9 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -86,7 +86,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { UserId: users[1].ID, Role: org.RoleEditor, } - err := sqlStore.AddOrgUser(context.Background(), cmd) + err := sqlStore.addOrgUser(context.Background(), cmd) require.NoError(t, err) // add 3rd user as viewer @@ -95,7 +95,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { UserId: users[2].ID, Role: org.RoleViewer, } - err = sqlStore.AddOrgUser(context.Background(), cmd) + err = sqlStore.addOrgUser(context.Background(), cmd) require.NoError(t, err) // add 1st user as admin @@ -104,7 +104,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { UserId: users[0].ID, Role: org.RoleAdmin, } - err = sqlStore.AddOrgUser(context.Background(), cmd) + err = sqlStore.addOrgUser(context.Background(), cmd) require.NoError(t, err) // force renewal of user stats diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 8cd1270e9b2..9b97b5fda9c 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -394,12 +394,6 @@ func getTeamSelectSQLBase(filteredUsers []string) string { ` FROM team as team ` } -func (ss *SQLStore) DeleteUser(ctx context.Context, cmd *models.DeleteUserCommand) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - return deleteUserInTransaction(ss, sess, cmd) - }) -} - func (ss *SQLStore) DeleteUserInSession(ctx context.Context, sess *DBSession, cmd *models.DeleteUserCommand) error { return deleteUserInTransaction(ss, sess, cmd) } diff --git a/pkg/services/updatechecker/plugins.go b/pkg/services/updatechecker/plugins.go index 8ba7c27348d..bd3ce31896d 100644 --- a/pkg/services/updatechecker/plugins.go +++ b/pkg/services/updatechecker/plugins.go @@ -146,7 +146,7 @@ func canUpdate(v1, v2 string) bool { } func (s *PluginsService) pluginIDsCSV(m map[string]plugins.PluginDTO) string { - var ids []string + ids := make([]string, 0, len(m)) for pluginID := range m { ids = append(ids, pluginID) } diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index a460949a706..ee50e622781 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -13,6 +13,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" @@ -25,6 +27,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { } ss := db.InitTestDB(t) + quotaService := quotaimpl.ProvideService(ss, ss.Cfg) + orgService, err := orgimpl.ProvideService(ss, ss.Cfg, quotaService) + require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) usr := &user.SignedInUser{ OrgID: 1, @@ -265,9 +270,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { IsDisabled: false, } }) - err := ss.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + err := orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{ LoginOrEmail: users[1].Login, Role: org.RoleViewer, - OrgId: users[0].OrgID, UserId: users[1].ID, + OrgID: users[0].OrgID, UserID: users[1].ID, }) require.Nil(t, err) @@ -402,9 +407,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { } }) - err = ss.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + err = orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{ LoginOrEmail: users[1].Login, Role: org.RoleViewer, - OrgId: users[0].OrgID, UserId: users[1].ID, + OrgID: users[0].OrgID, UserID: users[1].ID, }) require.Nil(t, err) @@ -415,15 +420,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) // When the user is deleted - err = ss.DeleteUser(context.Background(), &models.DeleteUserCommand{UserId: users[1].ID}) + err = userStore.Delete(context.Background(), users[1].ID) require.Nil(t, err) - query1 := &org.GetOrgUsersQuery{OrgID: users[0].OrgID, User: usr} - query1Result, err := userStore.getOrgUsersForTest(context.Background(), query1) - require.Nil(t, err) - - require.Len(t, query1Result, 1) - permQuery := &models.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: users[0].OrgID} err = userStore.getDashboardACLInfoList(permQuery) require.Nil(t, err) @@ -441,9 +440,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { IsDisabled: false, } }) - err = ss.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + err = orgService.AddOrgUser(context.Background(), &org.AddOrgUserCommand{ LoginOrEmail: users[1].Login, Role: org.RoleViewer, - OrgId: users[0].OrgID, UserId: users[1].ID, + OrgID: users[0].OrgID, UserID: users[1].ID, }) require.Nil(t, err) @@ -487,16 +486,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.EqualValues(t, query5Result.TotalCount, 5) // the user is deleted - err = ss.DeleteUser(context.Background(), &models.DeleteUserCommand{UserId: users[1].ID}) + err = userStore.Delete(context.Background(), users[1].ID) require.Nil(t, err) - // delete connected org users and permissions - query2 := &org.GetOrgUsersQuery{OrgID: users[0].OrgID} - query2Result, err := userStore.getOrgUsersForTest(context.Background(), query2) - require.Nil(t, err) - - require.Len(t, query2Result, 1) - permQuery = &models.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: users[0].OrgID} err = userStore.getDashboardACLInfoList(permQuery) require.Nil(t, err) @@ -845,20 +837,6 @@ func updateDashboardACL(t *testing.T, sqlStore db.DB, dashboardID int64, items . return err } -func (ss *sqlStore) getOrgUsersForTest(ctx context.Context, query *org.GetOrgUsersQuery) ([]*org.OrgUserDTO, error) { - result := make([]*org.OrgUserDTO, 0) - err := ss.db.WithDbSession(ctx, func(dbSess *db.Session) error { - sess := dbSess.Table("org_user") - sess.Join("LEFT ", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user"))) - sess.Where("org_user.org_id=?", query.OrgID) - sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role") - - err := sess.Find(&result) - return err - }) - return result, err -} - // This function was copied from pkg/services/dashboards/database to circumvent // import cycles. When this org-related code is refactored into a service the // tests can the real GetDashboardACLInfoList functions diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index f2250a5245d..132f556a1c2 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -232,11 +232,15 @@ func (s *Service) SetUsingOrg(ctx context.Context, cmd *user.SetUsingOrgCommand) func (s *Service) GetSignedInUserWithCacheCtx(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) { var signedInUser *user.SignedInUser - cacheKey := newSignedInUserCacheKey(query.OrgID, query.UserID) - if cached, found := s.cacheService.Get(cacheKey); found { - cachedUser := cached.(user.SignedInUser) - signedInUser = &cachedUser - return signedInUser, nil + + // only check cache if we have a user ID and an org ID in query + if query.OrgID > 0 && query.UserID > 0 { + cacheKey := newSignedInUserCacheKey(query.OrgID, query.UserID) + if cached, found := s.cacheService.Get(cacheKey); found { + cachedUser := cached.(user.SignedInUser) + signedInUser = &cachedUser + return signedInUser, nil + } } result, err := s.GetSignedInUser(ctx, query) @@ -244,7 +248,7 @@ func (s *Service) GetSignedInUserWithCacheCtx(ctx context.Context, query *user.G return nil, err } - cacheKey = newSignedInUserCacheKey(result.OrgID, query.UserID) + cacheKey := newSignedInUserCacheKey(result.OrgID, result.UserID) s.cacheService.Set(cacheKey, *result, time.Second*5) return result, nil } diff --git a/pkg/services/userauth/userauth.go b/pkg/services/userauth/userauth.go deleted file mode 100644 index e0cb1f5c7ee..00000000000 --- a/pkg/services/userauth/userauth.go +++ /dev/null @@ -1,8 +0,0 @@ -package userauth - -import "context" - -type Service interface { - Delete(context.Context, int64) error - DeleteToken(context.Context, int64) error -} diff --git a/pkg/services/userauth/userauthimpl/store.go b/pkg/services/userauth/userauthimpl/store.go deleted file mode 100644 index e563eb396fe..00000000000 --- a/pkg/services/userauth/userauthimpl/store.go +++ /dev/null @@ -1,32 +0,0 @@ -package userauthimpl - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/db" -) - -type store interface { - Delete(context.Context, int64) error - DeleteToken(context.Context, int64) error -} - -type sqlStore struct { - db db.DB -} - -func (ss *sqlStore) Delete(ctx context.Context, userID int64) error { - return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = "DELETE FROM user_auth WHERE user_id = ?" - _, err := sess.Exec(rawSQL, userID) - return err - }) -} - -func (ss *sqlStore) DeleteToken(ctx context.Context, userID int64) error { - return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = "DELETE FROM user_auth_token WHERE user_id = ?" - _, err := sess.Exec(rawSQL, userID) - return err - }) -} diff --git a/pkg/services/userauth/userauthimpl/store_test.go b/pkg/services/userauth/userauthimpl/store_test.go deleted file mode 100644 index 5b29b735666..00000000000 --- a/pkg/services/userauth/userauthimpl/store_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package userauthimpl - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" -) - -func TestIntegrationUserAuthDataAccess(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - ss := db.InitTestDB(t) - userAuthStore := sqlStore{ - db: ss, - } - - t.Run("delete user auth", func(t *testing.T) { - err := userAuthStore.Delete(context.Background(), 1) - require.NoError(t, err) - }) - - t.Run("delete user auth token", func(t *testing.T) { - err := userAuthStore.DeleteToken(context.Background(), 1) - require.NoError(t, err) - }) -} diff --git a/pkg/services/userauth/userauthimpl/userauth.go b/pkg/services/userauth/userauthimpl/userauth.go deleted file mode 100644 index 23367fb9ea6..00000000000 --- a/pkg/services/userauth/userauthimpl/userauth.go +++ /dev/null @@ -1,28 +0,0 @@ -package userauthimpl - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/userauth" -) - -type Service struct { - store store -} - -func ProvideService(db db.DB) userauth.Service { - return &Service{ - store: &sqlStore{ - db: db, - }, - } -} - -func (s *Service) Delete(ctx context.Context, userID int64) error { - return s.store.Delete(ctx, userID) -} - -func (s *Service) DeleteToken(ctx context.Context, userID int64) error { - return s.store.DeleteToken(ctx, userID) -} diff --git a/pkg/services/userauth/userauthimpl/userauth_test.go b/pkg/services/userauth/userauthimpl/userauth_test.go deleted file mode 100644 index 7c29f57d422..00000000000 --- a/pkg/services/userauth/userauthimpl/userauth_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package userauthimpl - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestUserAuthService(t *testing.T) { - userAuthStore := &FakeUserAuthStore{} - userAuthService := Service{ - store: userAuthStore, - } - - t.Run("delete user", func(t *testing.T) { - err := userAuthService.Delete(context.Background(), 1) - require.NoError(t, err) - }) - - t.Run("delete token", func(t *testing.T) { - err := userAuthService.DeleteToken(context.Background(), 1) - require.NoError(t, err) - }) -} - -type FakeUserAuthStore struct { - ExpectedError error -} - -func (f *FakeUserAuthStore) Delete(ctx context.Context, userID int64) error { - return f.ExpectedError -} - -func (f *FakeUserAuthStore) DeleteToken(ctx context.Context, userID int64) error { - return f.ExpectedError -} diff --git a/pkg/services/userauth/userauthtest/fake.go b/pkg/services/userauth/userauthtest/fake.go deleted file mode 100644 index d7a3b0bb7d7..00000000000 --- a/pkg/services/userauth/userauthtest/fake.go +++ /dev/null @@ -1,19 +0,0 @@ -package userauthtest - -import "context" - -type FakeUserAuthService struct { - ExpectedError error -} - -func NewFakeUserAuthService() *FakeUserAuthService { - return &FakeUserAuthService{} -} - -func (f *FakeUserAuthService) Delete(ctx context.Context, userID int64) error { - return f.ExpectedError -} - -func (f *FakeUserAuthService) DeleteToken(ctx context.Context, userID int64) error { - return f.ExpectedError -} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 546974bbe8c..4754d5c028e 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -385,6 +385,9 @@ type Cfg struct { HiddenUsers map[string]struct{} CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + // Service Accounts + SATokenExpirationDayLimit int + // Annotations AnnotationCleanupJobBatchSize int64 AnnotationMaximumTagsLength int64 @@ -424,9 +427,9 @@ type Cfg struct { LDAPSkipOrgRoleSync bool LDAPAllowSignup bool - DefaultTheme string - DefaultLocale string - HomePage string + DefaultTheme string + DefaultLanguage string + HomePage string Quota QuotaSettings @@ -978,6 +981,9 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { if err := readUserSettings(iniFile, cfg); err != nil { return err } + if err := readServiceAccountSettings(iniFile, cfg); err != nil { + return err + } if err := readAuthSettings(iniFile, cfg); err != nil { return err } @@ -1449,7 +1455,7 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { LoginHint = valueAsString(users, "login_hint", "") PasswordHint = valueAsString(users, "password_hint", "") cfg.DefaultTheme = valueAsString(users, "default_theme", "") - cfg.DefaultLocale = valueAsString(users, "default_locale", "") + cfg.DefaultLanguage = valueAsString(users, "default_language", "") cfg.HomePage = valueAsString(users, "home_page", "") ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "") ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "") @@ -1481,6 +1487,12 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { return nil } +func readServiceAccountSettings(iniFile *ini.File, cfg *Cfg) error { + serviceAccount := iniFile.Section("service_accounts") + cfg.SATokenExpirationDayLimit = serviceAccount.Key("token_expiration_day_limit").MustInt(-1) + return nil +} + func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) error { renderSec := iniFile.Section("rendering") cfg.RendererUrl = valueAsString(renderSec, "server_url", "") diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 1d8fcbb3ed7..6e0ee10df70 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -20,7 +20,7 @@ const ( alertmanagerDefaultPeerTimeout = 15 * time.Second alertmanagerDefaultGossipInterval = cluster.DefaultGossipInterval alertmanagerDefaultPushPullInterval = cluster.DefaultPushPullInterval - alertmanagerDefaultConfigPollInterval = 60 * time.Second + alertmanagerDefaultConfigPollInterval = time.Minute // To start, the alertmanager needs at least one route defined. // TODO: we should move this to Grafana settings and define this as the default. alertmanagerDefaultConfiguration = `{ @@ -45,7 +45,7 @@ const ( } ` evaluatorDefaultEvaluationTimeout = 30 * time.Second - schedulerDefaultAdminConfigPollInterval = 60 * time.Second + schedulerDefaultAdminConfigPollInterval = time.Minute schedulereDefaultExecuteAlerts = true schedulerDefaultMaxAttempts = 3 schedulerDefaultLegacyMinInterval = 1 diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go index 5968688483c..92fa07648ad 100644 --- a/pkg/setting/setting_unified_alerting_test.go +++ b/pkg/setting/setting_unified_alerting_test.go @@ -17,14 +17,14 @@ func TestCfg_ReadUnifiedAlertingSettings(t *testing.T) { // It sets the correct defaults. { - require.Equal(t, 60*time.Second, cfg.UnifiedAlerting.AdminConfigPollInterval) - require.Equal(t, 60*time.Second, cfg.UnifiedAlerting.AlertmanagerConfigPollInterval) + require.Equal(t, time.Minute, cfg.UnifiedAlerting.AdminConfigPollInterval) + require.Equal(t, time.Minute, cfg.UnifiedAlerting.AlertmanagerConfigPollInterval) require.Equal(t, 15*time.Second, cfg.UnifiedAlerting.HAPeerTimeout) require.Equal(t, "0.0.0.0:9094", cfg.UnifiedAlerting.HAListenAddr) require.Equal(t, "", cfg.UnifiedAlerting.HAAdvertiseAddr) require.Len(t, cfg.UnifiedAlerting.HAPeers, 0) require.Equal(t, 200*time.Millisecond, cfg.UnifiedAlerting.HAGossipInterval) - require.Equal(t, 60*time.Second, cfg.UnifiedAlerting.HAPushPullInterval) + require.Equal(t, time.Minute, cfg.UnifiedAlerting.HAPushPullInterval) } // With peers set, it correctly parses them. @@ -66,7 +66,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { verifyCfg: func(t *testing.T, cfg Cfg) { require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.AdminConfigPollInterval) require.Equal(t, int64(6), cfg.UnifiedAlerting.MaxAttempts) - require.Equal(t, 60*time.Second, cfg.UnifiedAlerting.MinInterval) + require.Equal(t, time.Minute, cfg.UnifiedAlerting.MinInterval) require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 90*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index deeaec99eed..09d2be1a8ec 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -266,7 +266,7 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { { require.Eventually(t, func() bool { return fakeAM1.AlertsCount() == 1 && fakeAM2.AlertsCount() == 1 - }, 60*time.Second, 5*time.Second) + }, time.Minute, 5*time.Second) } // Add an alertmanager datasource fot the other organisation diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index a4ca9ab479a..a5464ddfdae 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -2540,8 +2541,10 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma } func createOrg(t *testing.T, store *sqlstore.SQLStore, name string, userID int64) int64 { - org, err := store.CreateOrgWithMember(name, userID) + cmd := &models.CreateOrgCommand{Name: name, UserId: userID} + err := store.CreateOrg(context.Background(), cmd) require.NoError(t, err) + org := cmd.Result return org.Id } diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index f3d82c3614e..9950b4672a8 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -724,51 +724,6 @@ "signatureType": "", "signatureOrg": "" }, - { - "name": "Heatmap (legacy)", - "type": "panel", - "id": "heatmap-old", - "enabled": true, - "pinned": false, - "info": { - "author": { - "name": "Grafana Labs", - "url": "https://grafana.com" - }, - "description": "Legacy heatmap panel based on angular, d3, and flot", - "links": [ - { - "name": "Brendan Gregg - Heatmaps", - "url": "http://www.brendangregg.com/heatmaps.html" - }, - { - "name": "Brendan Gregg - Latency Heatmaps", - "url": " http://www.brendangregg.com/HeatMaps/latency.html" - } - ], - "logos": { - "small": "public/app/plugins/panel/heatmap-old/img/icn-heatmap-panel.svg", - "large": "public/app/plugins/panel/heatmap-old/img/icn-heatmap-panel.svg" - }, - "build": {}, - "screenshots": null, - "version": "", - "updated": "" - }, - "dependencies": { - "grafanaDependency": "", - "grafanaVersion": "*", - "plugins": [] - }, - "latestVersion": "", - "hasUpdate": false, - "defaultNavUrl": "/plugins/heatmap-old/", - "category": "", - "state": "deprecated", - "signature": "internal", - "signatureType": "", - "signatureOrg": "" - }, { "name": "Histogram", "type": "panel", diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index 826b63e4246..b56256ad808 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -216,7 +216,8 @@ func (s *Service) getDSInfo(pluginCtx backend.PluginContext) (types.DatasourceIn } func checkAzureMonitorMetricsHealth(dsInfo types.DatasourceInfo) (*http.Response, error) { - url := fmt.Sprintf("%v/subscriptions?api-version=%v", dsInfo.Routes["Azure Monitor"].URL, metrics.AzureMonitorAPIVersion) + subscriptionsApiVersion := "2020-01-01" + url := fmt.Sprintf("%v/subscriptions?api-version=%v", dsInfo.Routes["Azure Monitor"].URL, subscriptionsApiVersion) request, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err diff --git a/pkg/tsdb/azuremonitor/azuremonitor_test.go b/pkg/tsdb/azuremonitor/azuremonitor_test.go index e2d428b9ddd..74a479564af 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor_test.go @@ -425,7 +425,7 @@ func TestCheckHealth(t *testing.T) { Status: backend.HealthStatusError, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( - `{"verboseMessage": "1. Error connecting to Azure Monitor endpoint: health check failed: Get \"https://management.azure.com/subscriptions?api-version=2021-05-01\": not found\n2. Error connecting to Azure Log Analytics endpoint: health check failed: Get \"https://management.azure.com/subscriptions//providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview\": not found\n3. Error connecting to Azure Resource Graph endpoint: health check failed: Post \"https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-06-01-preview\": not found" }`), + `{"verboseMessage": "1. Error connecting to Azure Monitor endpoint: health check failed: Get \"https://management.azure.com/subscriptions?api-version=2020-01-01\": not found\n2. Error connecting to Azure Log Analytics endpoint: health check failed: Get \"https://management.azure.com/subscriptions//providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview\": not found\n3. Error connecting to Azure Resource Graph endpoint: health check failed: Post \"https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-06-01-preview\": not found" }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index cda34cea065..83ba706cbbf 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -190,7 +190,10 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, logger l if err != nil { return dataResponseErrorWithExecuted(err) } - appendErrorNotice(frame, logResponse.Error) + frame = appendErrorNotice(frame, logResponse.Error) + if frame == nil { + return dataResponse + } model, err := simplejson.NewJson(query.JSON) if err != nil { @@ -222,10 +225,15 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, logger l return dataResponse } -func appendErrorNotice(frame *data.Frame, err *AzureLogAnalyticsAPIError) { - if err != nil { - frame.AppendNotices(apiErrorToNotice(err)) +func appendErrorNotice(frame *data.Frame, err *AzureLogAnalyticsAPIError) *data.Frame { + if err == nil { + return frame } + if frame == nil { + frame = &data.Frame{} + } + frame.AppendNotices(apiErrorToNotice(err)) + return frame } func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, logger log.Logger, url string) (*http.Request, error) { @@ -315,6 +323,10 @@ type LogAnalyticsMeta struct { } func setAdditionalFrameMeta(frame *data.Frame, query, subscriptionID, workspace string) error { + if frame.Meta == nil || frame.Meta.Custom == nil { + // empty response + return nil + } frame.Meta.ExecutedQueryString = query la, ok := frame.Meta.Custom.(*LogAnalyticsMeta) if !ok { diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go index 008d85f329a..e70fd49fe75 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -242,3 +243,11 @@ func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) { t.Error("expecting the error to inform of bad credentials") } } + +func Test_setAdditionalFrameMeta(t *testing.T) { + t.Run("it should not error with an empty response", func(t *testing.T) { + frame := data.NewFrame("test") + err := setAdditionalFrameMeta(frame, "", "", "") + require.NoError(t, err) + }) +} diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go index f274d9e2cf4..ad28b2a0e08 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go @@ -45,12 +45,7 @@ func apiErrorToNotice(err *AzureLogAnalyticsAPIError) data.Notice { // ResponseTableToFrame converts an AzureResponseTable to a data.Frame. func ResponseTableToFrame(table *types.AzureResponseTable, refID string, executedQuery string) (*data.Frame, error) { if len(table.Rows) == 0 { - return &data.Frame{ - RefID: refID, - Meta: &data.FrameMeta{ - ExecutedQueryString: executedQuery, - }, - }, nil + return nil, nil } converterFrame, err := converterFrameForTable(table) diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go index ba3ae060e79..afda6263816 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go @@ -173,12 +173,7 @@ func TestLogTableToFrame(t *testing.T) { name: "empty data response", testFile: "loganalytics/11-log-analytics-response-empty.json", expectedFrame: func() *data.Frame { - return &data.Frame{ - RefID: "A", - Meta: &data.FrameMeta{ - ExecutedQueryString: "query", - }, - } + return nil }, }, } diff --git a/pkg/tsdb/cloudwatch/annotation_query_test.go b/pkg/tsdb/cloudwatch/annotation_query_test.go index 5185ce2c889..e76dcce8ed4 100644 --- a/pkg/tsdb/cloudwatch/annotation_query_test.go +++ b/pkg/tsdb/cloudwatch/annotation_query_test.go @@ -32,7 +32,7 @@ func TestQuery_AnnotationQuery(t *testing.T) { t.Run("DescribeAlarmsForMetric is called with minimum parameters", func(t *testing.T) { client = fakeCWAnnotationsClient{describeAlarmsForMetricOutput: &cloudwatch.DescribeAlarmsForMetricOutput{}} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -66,7 +66,7 @@ func TestQuery_AnnotationQuery(t *testing.T) { t.Run("DescribeAlarms is called when prefixMatching is true", func(t *testing.T) { client = fakeCWAnnotationsClient{describeAlarmsOutput: &cloudwatch.DescribeAlarmsOutput{}} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index b93ba732f16..e78dfb8318f 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -50,7 +50,7 @@ type DataQueryJson struct { } type DataSource struct { - Settings *models.CloudWatchSettings + Settings models.CloudWatchSettings HTTPClient *http.Client } @@ -63,7 +63,7 @@ const ( logStreamIdentifierInternal = "__logstream__grafana_internal__" alertMaxAttempts = 8 - alertPollPeriod = 1000 * time.Millisecond + alertPollPeriod = time.Second logsQueryMode = "Logs" // QueryTypes diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index c4ec23dc1dd..bca9bbe4e80 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -52,7 +53,7 @@ func TestNewInstanceSettings(t *testing.T) { }, }, expectedDS: DataSource{ - Settings: &models.CloudWatchSettings{ + Settings: models.CloudWatchSettings{ AWSDatasourceSettings: awsds.AWSDatasourceSettings{ Profile: "foo", Region: "us-east2", @@ -112,7 +113,7 @@ func Test_CheckHealth(t *testing.T) { t.Run("successfully query metrics and logs", func(t *testing.T) { client = fakeCheckHealthClient{} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -133,7 +134,7 @@ func Test_CheckHealth(t *testing.T) { return nil, fmt.Errorf("some logs query error") }} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -154,7 +155,7 @@ func Test_CheckHealth(t *testing.T) { return fmt.Errorf("some list metrics error") }} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -172,7 +173,7 @@ func Test_CheckHealth(t *testing.T) { t.Run("fail to get clients", func(t *testing.T) { client = fakeCheckHealthClient{} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{getSession: func(c awsds.SessionConfig) (*session.Session, error) { return nil, fmt.Errorf("some sessions error") @@ -203,7 +204,7 @@ func Test_executeLogAlertQuery(t *testing.T) { t.Run("getCWLogsClient is called with region from input JSON", func(t *testing.T) { cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) sess := fakeSessionCache{} executor := newExecutor(im, newTestConfig(), &sess, featuremgmt.WithFeatures()) @@ -229,7 +230,7 @@ func Test_executeLogAlertQuery(t *testing.T) { t.Run("getCWLogsClient is called with region from instance manager when region is default", func(t *testing.T) { cli = fakeCWLogsClient{queryResults: cloudwatchlogs.GetQueryResultsOutput{Status: aws.String("Complete")}} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}}, nil + return DataSource{Settings: models.CloudWatchSettings{AWSDatasourceSettings: awsds.AWSDatasourceSettings{Region: "instance manager's region"}}}, nil }) sess := fakeSessionCache{} @@ -266,7 +267,7 @@ func TestQuery_ResourceRequest_DescribeAllLogGroups(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -412,7 +413,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -467,7 +468,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -505,7 +506,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -546,7 +547,7 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { return &api } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) t.Run("Should handle dimension value request and return values from the api", func(t *testing.T) { @@ -699,10 +700,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { sent := sender.Response require.NotNil(t, sent) require.Equal(t, http.StatusOK, sent.Status) - res := []models.Metric{} + res := []resources.Metric{} err = json.Unmarshal(sent.Body, &res) require.Nil(t, err) - assert.Equal(t, []models.Metric{{Name: "Test_MetricName1", Namespace: "AWS/EC2"}, {Name: "Test_MetricName2", Namespace: "AWS/EC2"}, {Name: "Test_MetricName3", Namespace: "AWS/ECS"}, {Name: "Test_MetricName10", Namespace: "AWS/ECS"}, {Name: "Test_MetricName4", Namespace: "AWS/ECS"}, {Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}, res) + assert.Equal(t, []resources.Metric{{Name: "Test_MetricName1", Namespace: "AWS/EC2"}, {Name: "Test_MetricName2", Namespace: "AWS/EC2"}, {Name: "Test_MetricName3", Namespace: "AWS/ECS"}, {Name: "Test_MetricName10", Namespace: "AWS/ECS"}, {Name: "Test_MetricName4", Namespace: "AWS/ECS"}, {Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}, res) }) } diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 950df2f89f5..00985a930f8 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -83,7 +83,7 @@ func TestQuery_GetLogEvents(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -141,7 +141,7 @@ func TestQuery_GetLogGroupFields(t *testing.T) { const refID = "A" im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -222,7 +222,7 @@ func TestQuery_StartQuery(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -275,7 +275,7 @@ func TestQuery_StartQuery(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -333,7 +333,7 @@ func Test_executeStartQuery(t *testing.T) { t.Run("successfully parses information from JSON to StartQueryWithContext", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -369,7 +369,7 @@ func Test_executeStartQuery(t *testing.T) { t.Run("does not populate StartQueryInput.limit when no limit provided", func(t *testing.T) { cli = fakeCWLogsClient{} im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -425,7 +425,7 @@ func TestQuery_StopQuery(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) timeRange := backend.TimeRange{ @@ -520,7 +520,7 @@ func TestQuery_GetQueryResults(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 89e04c9eb07..9061430d4b7 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -41,7 +41,7 @@ func TestQuery_Regions(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -108,7 +108,7 @@ func TestQuery_InstanceAttributes(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) filterMap := map[string][]string{ @@ -191,7 +191,7 @@ func TestQuery_EBSVolumeIDs(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -251,7 +251,7 @@ func TestQuery_ResourceARNs(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) tagMap := map[string][]string{ diff --git a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go index 7bd44ca004b..e809bc8518c 100644 --- a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go +++ b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go @@ -1,8 +1,7 @@ package mocks import ( - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/mock" ) @@ -10,26 +9,26 @@ type ListMetricsServiceMock struct { mock.Mock } -func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(*request.DimensionKeysRequest) ([]string, error) { - args := a.Called() +func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) { + args := a.Called(r) return args.Get(0).([]string), args.Error(1) } -func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r *request.DimensionValuesRequest) ([]string, error) { - args := a.Called() +func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) { + args := a.Called(r) return args.Get(0).([]string), args.Error(1) } -func (a *ListMetricsServiceMock) GetDimensionKeysByNamespace(string) ([]string, error) { - args := a.Called() +func (a *ListMetricsServiceMock) GetDimensionKeysByNamespace(namespace string) ([]string, error) { + args := a.Called(namespace) return args.Get(0).([]string), args.Error(1) } -func (a *ListMetricsServiceMock) GetMetricsByNamespace(namespace string) ([]models.Metric, error) { - args := a.Called() +func (a *ListMetricsServiceMock) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) { + args := a.Called(namespace) - return args.Get(0).([]models.Metric), args.Error(1) + return args.Get(0).([]resources.Metric), args.Error(1) } diff --git a/pkg/tsdb/cloudwatch/models/metric_types.go b/pkg/tsdb/cloudwatch/models/api.go similarity index 59% rename from pkg/tsdb/cloudwatch/models/metric_types.go rename to pkg/tsdb/cloudwatch/models/api.go index ec6c1ed6087..b86860e5efd 100644 --- a/pkg/tsdb/cloudwatch/models/metric_types.go +++ b/pkg/tsdb/cloudwatch/models/api.go @@ -2,14 +2,14 @@ package models import ( "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) type ListMetricsProvider interface { - GetDimensionKeysByDimensionFilter(*request.DimensionKeysRequest) ([]string, error) + GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest) ([]string, error) GetDimensionKeysByNamespace(string) ([]string, error) - GetDimensionValuesByDimensionFilter(*request.DimensionValuesRequest) ([]string, error) - GetMetricsByNamespace(namespace string) ([]Metric, error) + GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest) ([]string, error) + GetMetricsByNamespace(namespace string) ([]resources.Metric, error) } type MetricsClientProvider interface { diff --git a/pkg/tsdb/cloudwatch/models/request/types.go b/pkg/tsdb/cloudwatch/models/request/types.go deleted file mode 100644 index e06440bc9c8..00000000000 --- a/pkg/tsdb/cloudwatch/models/request/types.go +++ /dev/null @@ -1,6 +0,0 @@ -package request - -type Dimension struct { - Name string - Value string -} diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go b/pkg/tsdb/cloudwatch/models/resources/dimension_keys_request.go similarity index 72% rename from pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_keys_request.go index 7713cdb6ce8..894943caa09 100644 --- a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/dimension_keys_request.go @@ -1,4 +1,4 @@ -package request +package resources import ( "net/url" @@ -9,7 +9,6 @@ type DimensionKeysRequestType uint32 const ( StandardDimensionKeysRequest DimensionKeysRequestType = iota FilterDimensionKeysRequest - CustomMetricDimensionKeysRequest ) type DimensionKeysRequest struct { @@ -20,24 +19,20 @@ type DimensionKeysRequest struct { } func (q *DimensionKeysRequest) Type() DimensionKeysRequestType { - if isCustomNamespace(q.Namespace) { - return CustomMetricDimensionKeysRequest - } - - if len(q.DimensionFilter) > 0 { + if isCustomNamespace(q.Namespace) || len(q.DimensionFilter) > 0 { return FilterDimensionKeysRequest } return StandardDimensionKeysRequest } -func GetDimensionKeysRequest(parameters url.Values) (*DimensionKeysRequest, error) { +func GetDimensionKeysRequest(parameters url.Values) (DimensionKeysRequest, error) { resourceRequest, err := getResourceRequest(parameters) if err != nil { - return nil, err + return DimensionKeysRequest{}, err } - request := &DimensionKeysRequest{ + request := DimensionKeysRequest{ ResourceRequest: resourceRequest, Namespace: parameters.Get("namespace"), MetricName: parameters.Get("metricName"), @@ -46,7 +41,7 @@ func GetDimensionKeysRequest(parameters url.Values) (*DimensionKeysRequest, erro dimensions, err := parseDimensionFilter(parameters.Get("dimensionFilters")) if err != nil { - return nil, err + return DimensionKeysRequest{}, err } request.DimensionFilter = dimensions diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go b/pkg/tsdb/cloudwatch/models/resources/dimension_keys_request_test.go similarity index 71% rename from pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_keys_request_test.go index 2285aefbc4e..f41936023de 100644 --- a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go +++ b/pkg/tsdb/cloudwatch/models/resources/dimension_keys_request_test.go @@ -1,4 +1,4 @@ -package request +package resources import ( "testing" @@ -69,4 +69,40 @@ func TestDimensionKeyRequest(t *testing.T) { assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) assert.Equal(t, "", request.DimensionFilter[0].Value) }) + + type testCase struct { + name string + dimensionKeysRequest DimensionKeysRequest + expectedType DimensionKeysRequestType + } + testCases := []testCase{ + { + name: "With custom namespace it should resolve to FilterDimensionKeysRequest", + dimensionKeysRequest: DimensionKeysRequest{ + Namespace: "custom", + }, + expectedType: FilterDimensionKeysRequest, + }, + { + name: "With dimension filter it should resolve to FilterDimensionKeysRequest", + dimensionKeysRequest: DimensionKeysRequest{ + Namespace: "AWS/EC2", + DimensionFilter: []*Dimension{{Name: "InstanceId", Value: "i-1234567890abcdef0"}}, + }, + expectedType: FilterDimensionKeysRequest, + }, + { + name: "With dimension filter and without custom namespace it should resolve to StandardDimensionKeysRequest", + dimensionKeysRequest: DimensionKeysRequest{ + Namespace: "AWS/EC2", + }, + expectedType: StandardDimensionKeysRequest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedType, tc.dimensionKeysRequest.Type()) + }) + } } diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_values_request.go b/pkg/tsdb/cloudwatch/models/resources/dimension_values_request.go similarity index 74% rename from pkg/tsdb/cloudwatch/models/request/dimension_values_request.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_values_request.go index d14a8f4236a..730234494fb 100644 --- a/pkg/tsdb/cloudwatch/models/request/dimension_values_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/dimension_values_request.go @@ -1,4 +1,4 @@ -package request +package resources import ( "net/url" @@ -12,13 +12,13 @@ type DimensionValuesRequest struct { DimensionFilter []*Dimension } -func GetDimensionValuesRequest(parameters url.Values) (*DimensionValuesRequest, error) { +func GetDimensionValuesRequest(parameters url.Values) (DimensionValuesRequest, error) { resourceRequest, err := getResourceRequest(parameters) if err != nil { - return nil, err + return DimensionValuesRequest{}, err } - request := &DimensionValuesRequest{ + request := DimensionValuesRequest{ ResourceRequest: resourceRequest, Namespace: parameters.Get("namespace"), MetricName: parameters.Get("metricName"), @@ -28,7 +28,7 @@ func GetDimensionValuesRequest(parameters url.Values) (*DimensionValuesRequest, dimensions, err := parseDimensionFilter(parameters.Get("dimensionFilters")) if err != nil { - return nil, err + return DimensionValuesRequest{}, err } request.DimensionFilter = dimensions diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go b/pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go similarity index 99% rename from pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go index 4a381555c04..cb73b2ad31f 100644 --- a/pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go +++ b/pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go @@ -1,4 +1,4 @@ -package request +package resources import ( "testing" diff --git a/pkg/tsdb/cloudwatch/models/request/metrics.go b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go similarity index 97% rename from pkg/tsdb/cloudwatch/models/request/metrics.go rename to pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go index a4f2e65fa45..6bc749348cd 100644 --- a/pkg/tsdb/cloudwatch/models/request/metrics.go +++ b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go @@ -1,4 +1,4 @@ -package request +package resources import ( "net/url" diff --git a/pkg/tsdb/cloudwatch/models/request/metrics_test.go b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request_test.go similarity index 98% rename from pkg/tsdb/cloudwatch/models/request/metrics_test.go rename to pkg/tsdb/cloudwatch/models/resources/metrics_resource_request_test.go index 0e1038a6669..9883503588f 100644 --- a/pkg/tsdb/cloudwatch/models/request/metrics_test.go +++ b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request_test.go @@ -1,4 +1,4 @@ -package request +package resources import ( "net/url" diff --git a/pkg/tsdb/cloudwatch/models/request/resource_request.go b/pkg/tsdb/cloudwatch/models/resources/resource_request.go similarity index 94% rename from pkg/tsdb/cloudwatch/models/request/resource_request.go rename to pkg/tsdb/cloudwatch/models/resources/resource_request.go index 5f541b0feb1..e13c11cb558 100644 --- a/pkg/tsdb/cloudwatch/models/request/resource_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/resource_request.go @@ -1,4 +1,4 @@ -package request +package resources import ( "fmt" diff --git a/pkg/tsdb/cloudwatch/models/request/resource_request_test.go b/pkg/tsdb/cloudwatch/models/resources/resource_request_test.go similarity index 88% rename from pkg/tsdb/cloudwatch/models/request/resource_request_test.go rename to pkg/tsdb/cloudwatch/models/resources/resource_request_test.go index 4f9f38f611e..666a13c897d 100644 --- a/pkg/tsdb/cloudwatch/models/request/resource_request_test.go +++ b/pkg/tsdb/cloudwatch/models/resources/resource_request_test.go @@ -1,4 +1,4 @@ -package request +package resources import ( "testing" @@ -10,7 +10,7 @@ import ( func TestResourceRequest(t *testing.T) { t.Run("Should return an error if region is not provided", func(t *testing.T) { request, err := GetDimensionValuesRequest(map[string][]string{}) - require.Nil(t, request) + require.Empty(t, request) assert.Equal(t, "region is required", err.Error()) }) } diff --git a/pkg/tsdb/cloudwatch/models/resources/types.go b/pkg/tsdb/cloudwatch/models/resources/types.go new file mode 100644 index 00000000000..a218740a438 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/resources/types.go @@ -0,0 +1,11 @@ +package resources + +type Dimension struct { + Name string + Value string +} + +type Metric struct { + Name string `json:"name"` + Namespace string `json:"namespace"` +} diff --git a/pkg/tsdb/cloudwatch/models/request/utils.go b/pkg/tsdb/cloudwatch/models/resources/utils.go similarity index 98% rename from pkg/tsdb/cloudwatch/models/request/utils.go rename to pkg/tsdb/cloudwatch/models/resources/utils.go index 4057a5f095d..22472876070 100644 --- a/pkg/tsdb/cloudwatch/models/request/utils.go +++ b/pkg/tsdb/cloudwatch/models/resources/utils.go @@ -1,4 +1,4 @@ -package request +package resources import ( "encoding/json" diff --git a/pkg/tsdb/cloudwatch/models/settings.go b/pkg/tsdb/cloudwatch/models/settings.go index 17b746c95d2..1f2cab0c3f0 100644 --- a/pkg/tsdb/cloudwatch/models/settings.go +++ b/pkg/tsdb/cloudwatch/models/settings.go @@ -13,11 +13,11 @@ type CloudWatchSettings struct { Namespace string `json:"customMetricsNamespaces"` } -func LoadCloudWatchSettings(config backend.DataSourceInstanceSettings) (*CloudWatchSettings, error) { - instance := &CloudWatchSettings{} +func LoadCloudWatchSettings(config backend.DataSourceInstanceSettings) (CloudWatchSettings, error) { + instance := CloudWatchSettings{} if config.JSONData != nil && len(config.JSONData) > 1 { - if err := json.Unmarshal(config.JSONData, instance); err != nil { - return nil, fmt.Errorf("could not unmarshal DatasourceSettings json: %w", err) + if err := json.Unmarshal(config.JSONData, &instance); err != nil { + return CloudWatchSettings{}, fmt.Errorf("could not unmarshal DatasourceSettings json: %w", err) } } diff --git a/pkg/tsdb/cloudwatch/models/types.go b/pkg/tsdb/cloudwatch/models/types.go index 0deec3522b0..df41487234d 100644 --- a/pkg/tsdb/cloudwatch/models/types.go +++ b/pkg/tsdb/cloudwatch/models/types.go @@ -8,7 +8,7 @@ import ( type RequestContext struct { MetricsClientProvider MetricsClientProvider - Settings *CloudWatchSettings + Settings CloudWatchSettings } type RequestContextFactoryFunc func(pluginCtx backend.PluginContext, region string) (reqCtx RequestContext, err error) @@ -35,8 +35,3 @@ type metricStatMeta struct { Period int `json:"period"` Label string `json:"label,omitempty"` } - -type Metric struct { - Name string `json:"name"` - Namespace string `json:"namespace"` -} diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index 9cb2ff2980f..fb9c6494942 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -121,8 +121,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb1"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -136,8 +136,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb2"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -185,8 +185,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb1 Sum"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -200,8 +200,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb2 Average"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -248,8 +248,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb3"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -263,8 +263,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb4"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), @@ -307,8 +307,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb3"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{}, StatusCode: aws.String("Complete"), @@ -346,8 +346,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb3"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{}, StatusCode: aws.String("Complete"), @@ -432,8 +432,8 @@ func TestCloudWatchResponseParser(t *testing.T) { Label: aws.String("lb"), Timestamps: []*time.Time{ aws.Time(timestamp), - aws.Time(timestamp.Add(60 * time.Second)), - aws.Time(timestamp.Add(180 * time.Second)), + aws.Time(timestamp.Add(time.Minute)), + aws.Time(timestamp.Add(3 * time.Minute)), }, Values: []*float64{ aws.Float64(10), diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys.go b/pkg/tsdb/cloudwatch/routes/dimension_keys.go index 7bba7258444..302225dbed3 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys.go @@ -7,12 +7,12 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) func DimensionKeysHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { - dimensionKeysRequest, err := request.GetDimensionKeysRequest(parameters) + dimensionKeysRequest, err := resources.GetDimensionKeysRequest(parameters) if err != nil { return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusBadRequest, err) } @@ -22,25 +22,23 @@ func DimensionKeysHandler(pluginCtx backend.PluginContext, reqCtxFactory models. return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusInternalServerError, err) } - dimensionKeys := []string{} + var response []string switch dimensionKeysRequest.Type() { - case request.StandardDimensionKeysRequest: - dimensionKeys, err = services.GetHardCodedDimensionKeysByNamespace(dimensionKeysRequest.Namespace) - case request.FilterDimensionKeysRequest: - dimensionKeys, err = service.GetDimensionKeysByDimensionFilter(dimensionKeysRequest) - case request.CustomMetricDimensionKeysRequest: - dimensionKeys, err = service.GetDimensionKeysByNamespace(dimensionKeysRequest.Namespace) + case resources.FilterDimensionKeysRequest: + response, err = service.GetDimensionKeysByDimensionFilter(dimensionKeysRequest) + default: + response, err = services.GetHardCodedDimensionKeysByNamespace(dimensionKeysRequest.Namespace) } if err != nil { return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusInternalServerError, err) } - dimensionKeysResponse, err := json.Marshal(dimensionKeys) + jsonResponse, err := json.Marshal(response) if err != nil { return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusInternalServerError, err) } - return dimensionKeysResponse, nil + return jsonResponse, nil } // newListMetricsService is an list metrics service factory. diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go index d86ccc9f98e..068e0fd4bca 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log/logtest" @@ -22,7 +24,14 @@ var logger = &logtest.Fake{} func Test_DimensionKeys_Route(t *testing.T) { t.Run("calls FilterDimensionKeysRequest when a StandardDimensionKeysRequest is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionKeysByDimensionFilter").Return([]string{}, nil) + mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.MatchedBy(func(r resources.DimensionKeysRequest) bool { + return r.ResourceRequest != nil && *r.ResourceRequest == resources.ResourceRequest{Region: "us-east-2"} && + r.Namespace == "AWS/EC2" && + r.MetricName == "CPUUtilization" && + len(r.DimensionFilter) == 2 && + assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) && + assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"}) + })).Return([]string{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -30,20 +39,6 @@ func Test_DimensionKeys_Route(t *testing.T) { req := httptest.NewRequest("GET", `/dimension-keys?region=us-east-2&namespace=AWS/EC2&metricName=CPUUtilization&dimensionFilters={"NodeID":["Shared"],"stage":["QueryCommit"]}`, nil) handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, logger, nil)) handler.ServeHTTP(rr, req) - mockListMetricsService.AssertNumberOfCalls(t, "GetDimensionKeysByDimensionFilter", 1) - }) - - t.Run("calls GetDimensionKeysByNamespace when a CustomMetricDimensionKeysRequest is passed", func(t *testing.T) { - mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionKeysByNamespace").Return([]string{}, nil) - newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { - return &mockListMetricsService, nil - } - rr := httptest.NewRecorder() - req := httptest.NewRequest("GET", `/dimension-keys?region=us-east-2&namespace=custom&metricName=CPUUtilization`, nil) - handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, logger, nil)) - handler.ServeHTTP(rr, req) - mockListMetricsService.AssertNumberOfCalls(t, "GetDimensionKeysByNamespace", 1) }) t.Run("calls GetHardCodedDimensionKeysByNamespace when a StandardDimensionKeysRequest is passed", func(t *testing.T) { @@ -62,7 +57,7 @@ func Test_DimensionKeys_Route(t *testing.T) { req := httptest.NewRequest("GET", "/dimension-keys?region=us-east-2&namespace=AWS/EC2&metricName=CPUUtilization", nil) handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, logger, nil)) handler.ServeHTTP(rr, req) - res := []models.Metric{} + res := []resources.Metric{} err := json.Unmarshal(rr.Body.Bytes(), &res) require.Nil(t, err) assert.True(t, haveBeenCalled) @@ -71,7 +66,7 @@ func Test_DimensionKeys_Route(t *testing.T) { t.Run("return 500 if GetDimensionKeysByDimensionFilter returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionKeysByDimensionFilter").Return([]string{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values.go b/pkg/tsdb/cloudwatch/routes/dimension_values.go index 1cbfe88d22d..810bee9f918 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_values.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_values.go @@ -7,11 +7,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) func DimensionValuesHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { - dimensionValuesRequest, err := request.GetDimensionValuesRequest(parameters) + dimensionValuesRequest, err := resources.GetDimensionValuesRequest(parameters) if err != nil { return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusBadRequest, err) } @@ -21,12 +21,12 @@ func DimensionValuesHandler(pluginCtx backend.PluginContext, reqCtxFactory model return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) } - dimensionValues, err := service.GetDimensionValuesByDimensionFilter(dimensionValuesRequest) + response, err := service.GetDimensionValuesByDimensionFilter(dimensionValuesRequest) if err != nil { return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) } - dimensionValuesResponse, err := json.Marshal(dimensionValues) + dimensionValuesResponse, err := json.Marshal(response) if err != nil { return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) } diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go index 444b6972d7b..cf365c1a090 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go @@ -7,7 +7,9 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" @@ -16,7 +18,15 @@ import ( func Test_DimensionValues_Route(t *testing.T) { t.Run("Calls GetDimensionValuesByDimensionFilter when a valid request is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionValuesByDimensionFilter").Return([]string{}, nil) + mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.MatchedBy(func(r resources.DimensionValuesRequest) bool { + return r.ResourceRequest != nil && *r.ResourceRequest == resources.ResourceRequest{Region: "us-east-2"} && + r.Namespace == "AWS/EC2" && + r.MetricName == "CPUUtilization" && + r.DimensionKey == "instanceId" && + len(r.DimensionFilter) == 2 && + assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) && + assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"}) + })).Return([]string{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -28,7 +38,7 @@ func Test_DimensionValues_Route(t *testing.T) { t.Run("returns 500 if GetDimensionValuesByDimensionFilter returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionValuesByDimensionFilter").Return([]string{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/metrics.go b/pkg/tsdb/cloudwatch/routes/metrics.go index 83b708bb2f0..db90f7b2f2c 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics.go +++ b/pkg/tsdb/cloudwatch/routes/metrics.go @@ -7,12 +7,12 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) func MetricsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { - metricsRequest, err := request.GetMetricsRequest(parameters) + metricsRequest, err := resources.GetMetricsRequest(parameters) if err != nil { return nil, models.NewHttpError("error in MetricsHandler", http.StatusBadRequest, err) } @@ -22,13 +22,13 @@ func MetricsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.Reques return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err) } - var metrics []models.Metric + var metrics []resources.Metric switch metricsRequest.Type() { - case request.AllMetricsRequestType: + case resources.AllMetricsRequestType: metrics = services.GetAllHardCodedMetrics() - case request.MetricsByNamespaceRequestType: + case resources.MetricsByNamespaceRequestType: metrics, err = services.GetHardCodedMetricsByNamespace(metricsRequest.Namespace) - case request.CustomNamespaceRequestType: + case resources.CustomNamespaceRequestType: metrics, err = service.GetMetricsByNamespace(metricsRequest.Namespace) } if err != nil { diff --git a/pkg/tsdb/cloudwatch/routes/metrics_test.go b/pkg/tsdb/cloudwatch/routes/metrics_test.go index 4cc991d6f43..061a846012c 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics_test.go +++ b/pkg/tsdb/cloudwatch/routes/metrics_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" @@ -19,7 +21,7 @@ import ( func Test_Metrics_Route(t *testing.T) { t.Run("calls GetMetricsByNamespace when a CustomNamespaceRequestType is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetMetricsByNamespace").Return([]models.Metric{}, nil) + mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, nil) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -36,15 +38,15 @@ func Test_Metrics_Route(t *testing.T) { services.GetAllHardCodedMetrics = origGetAllHardCodedMetrics }) haveBeenCalled := false - services.GetAllHardCodedMetrics = func() []models.Metric { + services.GetAllHardCodedMetrics = func() []resources.Metric { haveBeenCalled = true - return []models.Metric{} + return []resources.Metric{} } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/metrics?region=us-east-2", nil) handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil)) handler.ServeHTTP(rr, req) - res := []models.Metric{} + res := []resources.Metric{} err := json.Unmarshal(rr.Body.Bytes(), &res) require.Nil(t, err) assert.True(t, haveBeenCalled) @@ -57,16 +59,16 @@ func Test_Metrics_Route(t *testing.T) { }) haveBeenCalled := false usedNamespace := "" - services.GetHardCodedMetricsByNamespace = func(namespace string) ([]models.Metric, error) { + services.GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) { haveBeenCalled = true usedNamespace = namespace - return []models.Metric{}, nil + return []resources.Metric{}, nil } rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/metrics?region=us-east-2&namespace=AWS/DMS", nil) handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil)) handler.ServeHTTP(rr, req) - res := []models.Metric{} + res := []resources.Metric{} err := json.Unmarshal(rr.Body.Bytes(), &res) require.Nil(t, err) assert.True(t, haveBeenCalled) @@ -75,7 +77,7 @@ func Test_Metrics_Route(t *testing.T) { t.Run("returns 500 if GetMetricsByNamespace returns an error", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetMetricsByNamespace").Return([]models.Metric{}, fmt.Errorf("some error")) + mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, fmt.Errorf("some error")) newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } diff --git a/pkg/tsdb/cloudwatch/routes/namespaces_test.go b/pkg/tsdb/cloudwatch/routes/namespaces_test.go index 5f95525ebf9..9eeec6f0a94 100644 --- a/pkg/tsdb/cloudwatch/routes/namespaces_test.go +++ b/pkg/tsdb/cloudwatch/routes/namespaces_test.go @@ -16,7 +16,7 @@ func Test_Namespaces_Route(t *testing.T) { customNamespaces := "" factoryFunc := func(pluginCtx backend.PluginContext, region string) (reqCtx models.RequestContext, err error) { return models.RequestContext{ - Settings: &models.CloudWatchSettings{ + Settings: models.CloudWatchSettings{ Namespace: customNamespaces, }, }, nil diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go index 3a6079e1908..2268089b566 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, error) { @@ -16,8 +16,8 @@ var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, err return dimensionKeys, nil } -var GetHardCodedMetricsByNamespace = func(namespace string) ([]models.Metric, error) { - response := []models.Metric{} +var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) { + response := []resources.Metric{} exists := false var metrics []string if metrics, exists = constants.NamespaceMetricsMap[namespace]; !exists { @@ -25,17 +25,17 @@ var GetHardCodedMetricsByNamespace = func(namespace string) ([]models.Metric, er } for _, metric := range metrics { - response = append(response, models.Metric{Namespace: namespace, Name: metric}) + response = append(response, resources.Metric{Namespace: namespace, Name: metric}) } return response, nil } -var GetAllHardCodedMetrics = func() []models.Metric { - response := []models.Metric{} +var GetAllHardCodedMetrics = func() []resources.Metric { + response := []resources.Metric{} for namespace, metrics := range constants.NamespaceMetricsMap { for _, metric := range metrics { - response = append(response, models.Metric{Namespace: namespace, Name: metric}) + response = append(response, resources.Metric{Namespace: namespace, Name: metric}) } } diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go index ca821a18b85..4895ef9dc61 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go @@ -3,7 +3,7 @@ package services import ( "testing" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,6 +34,6 @@ func TestHardcodedMetrics_GetHardCodedMetricsByNamespace(t *testing.T) { t.Run("Should return metrics if namespace exist", func(t *testing.T) { resp, err := GetHardCodedMetricsByNamespace("AWS/IoTAnalytics") require.NoError(t, err) - assert.Equal(t, []models.Metric{{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}, {Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}, {Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}, resp) + assert.Equal(t, []resources.Metric{{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}, {Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}, {Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}, resp) }) } diff --git a/pkg/tsdb/cloudwatch/services/list_metrics.go b/pkg/tsdb/cloudwatch/services/list_metrics.go index 224890743dd..8ab2d2bebad 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics.go @@ -7,7 +7,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" ) type ListMetricsService struct { @@ -18,7 +18,7 @@ func NewListMetricsService(metricsClient models.MetricsClientProvider) models.Li return &ListMetricsService{metricsClient} } -func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *request.DimensionKeysRequest) ([]string, error) { +func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) { input := &cloudwatch.ListMetricsInput{} if r.Namespace != "" { input.Namespace = aws.String(r.Namespace) @@ -63,7 +63,7 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *request.Dimens return dimensionKeys, nil } -func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r *request.DimensionValuesRequest) ([]string, error) { +func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) { input := &cloudwatch.ListMetricsInput{ Namespace: aws.String(r.Namespace), MetricName: aws.String(r.MetricName), @@ -116,26 +116,26 @@ func (l *ListMetricsService) GetDimensionKeysByNamespace(namespace string) ([]st return dimensionKeys, nil } -func (l *ListMetricsService) GetMetricsByNamespace(namespace string) ([]models.Metric, error) { +func (l *ListMetricsService) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) { metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)}) if err != nil { return nil, err } - response := []models.Metric{} + response := []resources.Metric{} dupCheck := make(map[string]struct{}) for _, metric := range metrics { if _, exists := dupCheck[*metric.MetricName]; exists { continue } dupCheck[*metric.MetricName] = struct{}{} - response = append(response, models.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace}) + response = append(response, resources.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace}) } return response, nil } -func setDimensionFilter(input *cloudwatch.ListMetricsInput, dimensionFilter []*request.Dimension) { +func setDimensionFilter(input *cloudwatch.ListMetricsInput, dimensionFilter []*resources.Dimension) { for _, dimension := range dimensionFilter { df := &cloudwatch.DimensionFilter{ Name: aws.String(dimension.Name), diff --git a/pkg/tsdb/cloudwatch/services/list_metrics_test.go b/pkg/tsdb/cloudwatch/services/list_metrics_test.go index f2dc2db0c60..34548ffaa98 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics_test.go @@ -6,7 +6,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -47,11 +47,11 @@ func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) { fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) listMetricsService := NewListMetricsService(fakeMetricsClient) - resp, err := listMetricsService.GetDimensionKeysByDimensionFilter(&request.DimensionKeysRequest{ - ResourceRequest: &request.ResourceRequest{Region: "us-east-1"}, + resp, err := listMetricsService.GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"}, Namespace: "AWS/EC2", MetricName: "CPUUtilization", - DimensionFilter: []*request.Dimension{ + DimensionFilter: []*resources.Dimension{ {Name: "InstanceId", Value: ""}, }, }) @@ -80,12 +80,12 @@ func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) { fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) listMetricsService := NewListMetricsService(fakeMetricsClient) - resp, err := listMetricsService.GetDimensionValuesByDimensionFilter(&request.DimensionValuesRequest{ - ResourceRequest: &request.ResourceRequest{Region: "us-east-1"}, + resp, err := listMetricsService.GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest{ + ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"}, Namespace: "AWS/EC2", MetricName: "CPUUtilization", DimensionKey: "InstanceId", - DimensionFilter: []*request.Dimension{ + DimensionFilter: []*resources.Dimension{ {Name: "InstanceId", Value: ""}, }, }) diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index c978120662f..1dd7042ada6 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -50,7 +50,7 @@ func TestTimeSeriesQuery(t *testing.T) { }}}, nil) im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) @@ -150,7 +150,7 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) t.Run("Queries with the same region should call GetSession with that region 1 time and call GetMetricDataWithContext 1 time", func(t *testing.T) { @@ -342,7 +342,7 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { } im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + 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) { @@ -444,7 +444,7 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { }}, nil) im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - return DataSource{Settings: &models.CloudWatchSettings{}}, nil + return DataSource{Settings: models.CloudWatchSettings{}}, nil }) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures()) diff --git a/pkg/tsdb/intervalv2/intervalv2_test.go b/pkg/tsdb/intervalv2/intervalv2_test.go index d7adff32928..21b5a48abef 100644 --- a/pkg/tsdb/intervalv2/intervalv2_test.go +++ b/pkg/tsdb/intervalv2/intervalv2_test.go @@ -27,8 +27,8 @@ func TestIntervalCalculator_Calculate(t *testing.T) { {"from 15m to now and 100 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(15 * time.Minute)}, 100, "10s"}, {"from 30m to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(30 * time.Minute)}, 0, "1s"}, {"from 30m to now and 3000 resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(30 * time.Minute)}, 3000, "500ms"}, - {"from 1h to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(60 * time.Minute)}, 0, "2s"}, - {"from 1h to now and 1000 resoluion", backend.TimeRange{From: timeNow, To: timeNow.Add(60 * time.Minute)}, 1000, "5s"}, + {"from 1h to now and default resolution", backend.TimeRange{From: timeNow, To: timeNow.Add(time.Hour)}, 0, "2s"}, + {"from 1h to now and 1000 resoluion", backend.TimeRange{From: timeNow, To: timeNow.Add(time.Hour)}, 1000, "5s"}, } for _, tc := range testCases { diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 553cc9bdf4d..15743e5efeb 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -24,6 +24,11 @@ type LokiAPI struct { headers map[string]string } +type RawLokiResponse struct { + Body []byte + Encoding string +} + func newLokiAPI(client *http.Client, url string, log log.Logger, headers map[string]string) *LokiAPI { return &LokiAPI{client: client, url: url, log: log, headers: headers} } @@ -204,15 +209,15 @@ func makeRawRequest(ctx context.Context, lokiDsUrl string, resourcePath string, return req, nil } -func (api *LokiAPI) RawQuery(ctx context.Context, resourcePath string) ([]byte, error) { +func (api *LokiAPI) RawQuery(ctx context.Context, resourcePath string) (RawLokiResponse, error) { req, err := makeRawRequest(ctx, api.url, resourcePath, api.headers) if err != nil { - return nil, err + return RawLokiResponse{}, err } resp, err := api.client.Do(req) if err != nil { - return nil, err + return RawLokiResponse{}, err } defer func() { @@ -222,8 +227,18 @@ func (api *LokiAPI) RawQuery(ctx context.Context, resourcePath string) ([]byte, }() if resp.StatusCode/100 != 2 { - return nil, makeLokiError(resp.Body) + return RawLokiResponse{}, makeLokiError(resp.Body) } - return io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return RawLokiResponse{}, err + } + + encodedBytes := RawLokiResponse{ + Body: body, + Encoding: resp.Header.Get("Content-Encoding"), + } + + return encodedBytes, nil } diff --git a/pkg/tsdb/loki/api_mock.go b/pkg/tsdb/loki/api_mock.go index ae54fb85293..425c0a885a0 100644 --- a/pkg/tsdb/loki/api_mock.go +++ b/pkg/tsdb/loki/api_mock.go @@ -32,6 +32,29 @@ func (mockedRT *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response }, nil } +type mockedCompressedRoundTripper struct { + statusCode int + responseBytes []byte + contentType string + requestCallback mockRequestCallback +} + +func (mockedRT *mockedCompressedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + requestCallback := mockedRT.requestCallback + if requestCallback != nil { + requestCallback(req) + } + + header := http.Header{} + header.Add("Content-Type", mockedRT.contentType) + header.Add("Content-Encoding", "gzip") + return &http.Response{ + StatusCode: mockedRT.statusCode, + Header: header, + Body: io.NopCloser(bytes.NewReader(mockedRT.responseBytes)), + }, nil +} + func makeMockedAPI(statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback) *LokiAPI { return makeMockedAPIWithUrl("http://localhost:9999", statusCode, contentType, responseBytes, requestCallback) } @@ -43,3 +66,11 @@ func makeMockedAPIWithUrl(url string, statusCode int, contentType string, respon return newLokiAPI(&client, url, log.New("test"), nil) } + +func makeCompressedMockedAPIWithUrl(url string, statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback) *LokiAPI { + client := http.Client{ + Transport: &mockedCompressedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes, requestCallback: requestCallback}, + } + + return newLokiAPI(&client, url, log.New("test"), nil) +} diff --git a/pkg/tsdb/loki/api_test.go b/pkg/tsdb/loki/api_test.go index 4db38dd14f3..2b64e383bf4 100644 --- a/pkg/tsdb/loki/api_test.go +++ b/pkg/tsdb/loki/api_test.go @@ -150,3 +150,18 @@ func TestApiUrlHandling(t *testing.T) { }) } } + +func TestApiReturnValues(t *testing.T) { + t.Run("Loki should return the right encoding", func(t *testing.T) { + called := false + api := makeCompressedMockedAPIWithUrl("http://localhost:3100", 200, "application/json", []byte("foo"), func(req *http.Request) { + called = true + }) + + encodedBytes, err := api.RawQuery(context.Background(), "/loki/api/v1/labels?start=1&end=2") + require.NoError(t, err) + require.True(t, called) + require.Equal(t, "gzip", encodedBytes.Encoding) + require.Equal(t, []byte("foo"), encodedBytes.Body) + }) +} diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 49c1f46b909..6742398a226 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/textproto" "regexp" "strings" "sync" @@ -119,21 +120,27 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return callResource(ctx, req, sender, dsInfo, logger.FromContext(ctx)) } -func getAuthHeadersForCallResource(headers map[string][]string) map[string]string { +func getHeadersForCallResource(headers map[string][]string) map[string]string { data := make(map[string]string) - if auth := arrayHeaderFirstValue(headers["Authorization"]); auth != "" { - data["Authorization"] = auth - } + for k, values := range headers { + k = textproto.CanonicalMIMEHeaderKey(k) + firstValue := arrayHeaderFirstValue(values) - if cookie := arrayHeaderFirstValue(headers["Cookie"]); cookie != "" { - data["Cookie"] = cookie + if firstValue == "" { + continue + } + switch k { + case "Authorization": + data["Authorization"] = firstValue + case "X-Id-Token": + data["X-ID-Token"] = firstValue + case "Cookie": + data["Cookie"] = firstValue + case "Accept-Encoding": + data["Accept-Encoding"] = firstValue + } } - - if idToken := arrayHeaderFirstValue(headers["X-ID-Token"]); idToken != "" { - data["X-ID-Token"] = idToken - } - return data } @@ -151,19 +158,23 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender } lokiURL := fmt.Sprintf("/loki/api/v1/%s", url) - api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, getAuthHeadersForCallResource(req.Headers)) - bytes, err := api.RawQuery(ctx, lokiURL) + api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, getHeadersForCallResource(req.Headers)) + encodedBytes, err := api.RawQuery(ctx, lokiURL) if err != nil { return err } + respHeaders := map[string][]string{ + "content-type": {"application/json"}, + } + if encodedBytes.Encoding != "" { + respHeaders["content-encoding"] = []string{encodedBytes.Encoding} + } return sender.Send(&backend.CallResourceResponse{ - Status: http.StatusOK, - Headers: map[string][]string{ - "content-type": {"application/json"}, - }, - Body: bytes, + Status: http.StatusOK, + Headers: respHeaders, + Body: encodedBytes.Body, }) } diff --git a/pkg/tsdb/loki/loki_test.go b/pkg/tsdb/loki/loki_test.go new file mode 100644 index 00000000000..a7aa8a29524 --- /dev/null +++ b/pkg/tsdb/loki/loki_test.go @@ -0,0 +1,74 @@ +package loki + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetHeadersForCallResource(t *testing.T) { + const idTokn1 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + const idTokn2 = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJleHAiOjE2Njg2MjExODQsImlhdCI6MTY2ODYyMTE4NH0.bg0Y0S245DeANhNnnLBCfGYBseTld29O0xynhQwZZlU" + const authTokn1 = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + const authTokn2 = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJleHAiOjE2Njg2MjExODQsImlhdCI6MTY2ODYyMTE4NH0.bg0Y0S245DeANhNnnLBCfGYBseTld29O0xynhQwZZlU" + + testCases := map[string]struct { + headers map[string][]string + expectedHeaders map[string]string + }{ + "Headers with empty value": { + headers: map[string][]string{ + "X-Grafana-Org-Id": {"1"}, + "Cookie": {""}, + "X-Id-Token": {""}, + "Accept-Encoding": {""}, + "Authorization": {""}, + }, + expectedHeaders: map[string]string{}, + }, + "Headers with multiple values": { + headers: map[string][]string{ + "Authorization": {authTokn1, authTokn2}, + "Cookie": {"a=1"}, + "X-Grafana-Org-Id": {"1"}, + "Accept-Encoding": {"gzip", "compress"}, + "X-Id-Token": {idTokn1, idTokn2}, + }, + expectedHeaders: map[string]string{ + "Authorization": authTokn1, + "Cookie": "a=1", + "Accept-Encoding": "gzip", + "X-ID-Token": idTokn1, + }, + }, + "Headers with single value": { + headers: map[string][]string{ + "Authorization": {authTokn1}, + "X-Grafana-Org-Id": {"1"}, + "Cookie": {"a=1"}, + "Accept-Encoding": {"gzip"}, + "X-Id-Token": {idTokn1}, + }, + expectedHeaders: map[string]string{ + "Authorization": authTokn1, + "Cookie": "a=1", + "Accept-Encoding": "gzip", + "X-ID-Token": idTokn1, + }, + }, + "Non Canonical 'X-Id-Token' header key": { + headers: map[string][]string{ + "X-ID-TOKEN": {idTokn1}, + }, + expectedHeaders: map[string]string{ + "X-ID-Token": idTokn1, + }, + }, + } + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + headers := getHeadersForCallResource(test.headers) + assert.Equal(t, test.expectedHeaders, headers) + }) + } +} diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 705b35c5896..55705b9a73a 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -933,7 +933,7 @@ func TestMSSQL(t *testing.T) { } events := []*event{} - for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), time.Hour, 25*time.Minute) { events = append(events, &event{ TimeSec: t.Unix(), Description: "Someone deployed something", diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 043902ce527..58142d23190 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -905,7 +905,7 @@ func TestIntegrationMySQL(t *testing.T) { require.NoError(t, err) events := []*event{} - for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), time.Hour, 25*time.Minute) { events = append(events, &event{ TimeSec: t.Unix(), Description: "Someone deployed something", diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index 3b5094231e6..e48c79c5ef9 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -1003,7 +1003,7 @@ func TestIntegrationPostgres(t *testing.T) { require.NoError(t, err) events := []*event{} - for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), 60*time.Minute, 25*time.Minute) { + for _, t := range genTimeRangeByInterval(fromStart.Add(-20*time.Minute), time.Hour, 25*time.Minute) { events = append(events, &event{ TimeSec: t.Unix(), Description: "Someone deployed something", diff --git a/pkg/tsdb/prometheus/querydata/request.go b/pkg/tsdb/prometheus/querydata/request.go index e6ab6b4e123..cce1f4dfefc 100644 --- a/pkg/tsdb/prometheus/querydata/request.go +++ b/pkg/tsdb/prometheus/querydata/request.go @@ -185,7 +185,7 @@ func (s *QueryData) trace(ctx context.Context, q *models.Query) (context.Context } func sdkHeaderToHttpHeader(headers map[string]string) http.Header { - httpHeader := make(http.Header) + httpHeader := make(http.Header, len(headers)) for key, val := range headers { httpHeader[key] = []string{val} } diff --git a/pkg/util/cmd/cmd.go b/pkg/util/cmd/cmd.go new file mode 100644 index 00000000000..b0ec8b07501 --- /dev/null +++ b/pkg/util/cmd/cmd.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" +) + +func RunGrafanaCmd(subCmd string) int { + curr, err := os.Executable() + if err != nil { + fmt.Println("Error locating executable:", err) + return 1 + } + + executable := "grafana" + if runtime.GOOS == "windows" { + executable += ".exe" + } + + binary := filepath.Join(filepath.Dir(filepath.Clean(curr)), executable) + if _, err := os.Stat(binary); err != nil { + binary, err = exec.LookPath(executable) + if err != nil { + fmt.Printf("Error locating %s: %s\n", executable, err) + return 1 + } + } + + // windows doesn't support syscall.Exec so we just run the main binary as a command + if runtime.GOOS == "windows" { + // bypassing gosec G204 because we need to build the command programmatically + // nolint:gosec + cmd := exec.Command(binary, append([]string{subCmd}, os.Args[1:]...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + err := cmd.Run() + if err == nil { + return 0 + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode() + } + return 1 + } + + args := append([]string{"grafana", subCmd}, os.Args[1:]...) + + // bypassing gosec G204 because we need to build the command programmatically + // nolint:gosec + execErr := syscall.Exec(binary, args, os.Environ()) + if execErr != nil { + fmt.Printf("Error running %s: %s\n", binary, execErr) + return 1 + } + + return 0 +} diff --git a/public/app/app.ts b/public/app/app.ts index 466e6ba6b55..cdf836281f2 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -105,7 +105,7 @@ export class GrafanaApp { // Let iframe container know grafana has started loading parent.postMessage('GrafanaAppInit', '*'); - const loadLocalePromise = initializeI18n(config.bootData.user.locale); + const initI18nPromise = initializeI18n(config.bootData.user.language); setBackendSrv(backendSrv); initEchoSrv(); @@ -160,7 +160,7 @@ export class GrafanaApp { modalManager.init(); await Promise.all([ - loadLocalePromise, + initI18nPromise, // Preload selected app plugins await preloadPlugins(config.pluginsToPreload), diff --git a/public/app/core/components/AppChrome/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar.tsx index 12c4c46d481..59cc55b552e 100644 --- a/public/app/core/components/AppChrome/NavToolbar.tsx +++ b/public/app/core/components/AppChrome/NavToolbar.tsx @@ -33,7 +33,7 @@ export function NavToolbar({ }: Props) { const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const styles = useStyles2(getStyles); - const breadcrumbs = buildBreadcrumbs(homeNav, sectionNav, pageNav); + const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); return (
    diff --git a/public/app/core/components/Breadcrumbs/utils.test.ts b/public/app/core/components/Breadcrumbs/utils.test.ts index c84ca5d0c6d..c696d65a4d3 100644 --- a/public/app/core/components/Breadcrumbs/utils.test.ts +++ b/public/app/core/components/Breadcrumbs/utils.test.ts @@ -15,7 +15,7 @@ describe('breadcrumb utils', () => { text: 'My section', url: '/my-section', }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav)).toEqual([{ text: 'My section', href: '/my-section' }]); + expect(buildBreadcrumbs(sectionNav)).toEqual([{ text: 'My section', href: '/my-section' }]); }); it('includes breadcrumbs for the page nav', () => { @@ -28,7 +28,7 @@ describe('breadcrumb utils', () => { text: 'My page', url: '/my-page', }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([ { text: 'My section', href: '/my-section' }, { text: 'My page', href: '/my-page' }, ]); @@ -43,7 +43,7 @@ describe('breadcrumb utils', () => { url: '/my-parent-section', }, }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav)).toEqual([ { text: 'My parent section', href: '/my-parent-section' }, { text: 'My section', href: '/my-section' }, ]); @@ -66,7 +66,7 @@ describe('breadcrumb utils', () => { url: '/my-parent-section', }, }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([ { text: 'My parent section', href: '/my-parent-section' }, { text: 'My section', href: '/my-section' }, { text: 'My parent page', href: '/my-parent-page' }, @@ -91,7 +91,7 @@ describe('breadcrumb utils', () => { url: '/my-parent-section', }, }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([ { text: 'Home', href: '/home' }, { text: 'My page', href: '/my-page' }, ]); @@ -114,7 +114,7 @@ describe('breadcrumb utils', () => { url: '/my-parent-section', }, }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([ { text: 'Home', href: '/home?orgId=1' }, { text: 'My page', href: '/my-page' }, ]); @@ -137,7 +137,7 @@ describe('breadcrumb utils', () => { url: '/my-parent-section', }, }; - expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([ + expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([ { text: 'My parent section', href: '/my-parent-section' }, { text: 'My section', href: '/my-section' }, { text: 'My parent page', href: '/home?orgId=1&editview=settings' }, diff --git a/public/app/core/components/Breadcrumbs/utils.ts b/public/app/core/components/Breadcrumbs/utils.ts index c41022f7f26..cbf68fefb50 100644 --- a/public/app/core/components/Breadcrumbs/utils.ts +++ b/public/app/core/components/Breadcrumbs/utils.ts @@ -4,7 +4,7 @@ import { getNavTitle } from '../NavBar/navBarItem-translations'; import { Breadcrumb } from './types'; -export function buildBreadcrumbs(homeNav: NavModelItem, sectionNav: NavModelItem, pageNav?: NavModelItem) { +export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) { const crumbs: Breadcrumb[] = []; let foundHome = false; @@ -17,7 +17,7 @@ export function buildBreadcrumbs(homeNav: NavModelItem, sectionNav: NavModelItem urlToMatch += `?editview=${urlSearchParams.get('editview')}`; } if (!foundHome && !node.hideFromBreadcrumbs) { - if (urlToMatch === homeNav.url) { + if (homeNav && urlToMatch === homeNav.url) { crumbs.unshift({ text: getNavTitle(homeNav.id) ?? homeNav.text, href: node.url ?? '' }); foundHome = true; } else { diff --git a/public/app/core/components/MegaMenu/MegaMenu.tsx b/public/app/core/components/MegaMenu/MegaMenu.tsx index a6115e7988f..b9a4cef37c7 100644 --- a/public/app/core/components/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/MegaMenu/MegaMenu.tsx @@ -25,17 +25,14 @@ export const MegaMenu = React.memo(({ onClose, searchBarHidden }) => { const navTree = cloneDeep(navBarTree); const coreItems = navTree - .filter((item) => item.section === NavSection.Core) - .map((item) => enrichWithInteractionTracking(item, true)); - const pluginItems = navTree - .filter((item) => item.section === NavSection.Plugin) + .filter((item) => item.section === NavSection.Core || item.section === NavSection.Plugin) .map((item) => enrichWithInteractionTracking(item, true)); const configItems = enrichConfigItems( navTree.filter((item) => item.section === NavSection.Config && item && item.id !== 'help' && item.id !== 'profile'), location ).map((item) => enrichWithInteractionTracking(item, true)); - const navItems = [...coreItems, ...pluginItems, ...configItems]; + const navItems = [...coreItems, ...configItems]; const activeItem = getActiveItem(navItems, location.pathname); diff --git a/public/app/core/components/NavBar/NavBar.tsx b/public/app/core/components/NavBar/NavBar.tsx index e1765c71d61..9fc506b3ef0 100644 --- a/public/app/core/components/NavBar/NavBar.tsx +++ b/public/app/core/components/NavBar/NavBar.tsx @@ -61,7 +61,7 @@ export const NavBar = React.memo(() => { menuOpen ); - const navTree = cloneDeep(navBarTree); + const navTree = cloneDeep(navBarTree).filter((item) => item.hideFromMenu !== true); const coreItems = navTree .filter((item) => item.section === NavSection.Core) diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index 2a93cd7707e..ce3e58679c2 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -256,7 +256,7 @@ export function NavItem({ }} styleOverrides={styles.item} target={childLink.target} - text={childLink.text} + text={getNavTitle(childLink.id) ?? childLink.text} url={childLink.url} isMobile={true} /> @@ -297,7 +297,7 @@ export function NavItem({
    - {link.text} + {getNavTitle(link.id) ?? link.text}
    @@ -398,7 +398,7 @@ function CollapsibleNavItem({ contentClassName={styles.collapseContent} label={
    - {link.text} + {getNavTitle(link.id) ?? link.text}
    } > diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index db6a018ebb4..ea674073cf7 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -69,6 +69,12 @@ export function getNavTitle(navId: string | undefined) { return config.featureToggles.topnav ? t('nav.config.title', 'Administration') : t('nav.config.titleBeforeTopnav', 'Configuration'); + case 'admin/general': + return t('nav.admin-general.title', 'General'); + case 'admin/plugins': + return t('nav.admin-plugins.title', 'Plugins and data'); + case 'admin/access': + return t('nav.admin-access.title', 'Users and access'); case 'datasources': return t('nav.datasources.title', 'Data sources'); case 'correlations': @@ -80,7 +86,9 @@ export function getNavTitle(navId: string | undefined) { case 'plugins': return t('nav.plugins.title', 'Plugins'); case 'org-settings': - return t('nav.org-settings.title', 'Preferences'); + return config.featureToggles.topnav + ? t('nav.org-settings.title', 'Default preferences') + : t('nav.org-settings.titleBeforeTopnav', 'Preferences'); case 'apikeys': return t('nav.api-keys.title', 'API keys'); case 'serviceaccounts': @@ -88,7 +96,9 @@ export function getNavTitle(navId: string | undefined) { case 'admin': return t('nav.admin.title', 'Server admin'); case 'global-users': - return t('nav.global-users.title', 'Users'); + return config.featureToggles.topnav + ? t('nav.global-users.title', 'Users (All orgs)') + : t('nav.global-users.titleBeforeTopnav', 'Users'); case 'global-orgs': return t('nav.global-orgs.title', 'Organizations'); case 'server-settings': diff --git a/public/app/core/components/Page/usePageTitle.ts b/public/app/core/components/Page/usePageTitle.ts index 730c696fc27..7a9e0e025a5 100644 --- a/public/app/core/components/Page/usePageTitle.ts +++ b/public/app/core/components/Page/usePageTitle.ts @@ -1,37 +1,23 @@ import { useEffect } from 'react'; import { NavModel, NavModelItem } from '@grafana/data'; +import { HOME_NAV_ID } from 'app/core/reducers/navModel'; +import { useSelector } from 'app/types'; import { Branding } from '../Branding/Branding'; +import { buildBreadcrumbs } from '../Breadcrumbs/utils'; export function usePageTitle(navModel?: NavModel, pageNav?: NavModelItem) { + const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; useEffect(() => { - const parts: string[] = []; - if (pageNav) { - if (pageNav.children) { - const activePage = pageNav.children.find((x) => x.active); - if (activePage) { - addTitleSegment(parts, activePage); - } - } - addTitleSegment(parts, pageNav); - } + const sectionNav = (navModel?.node !== navModel?.main ? navModel?.node : navModel?.main) ?? { text: 'Grafana' }; + const parts: string[] = buildBreadcrumbs(sectionNav, pageNav, homeNav) + .map((crumb) => crumb.text) + .reverse(); - if (navModel) { - if (navModel.node !== navModel.main) { - addTitleSegment(parts, navModel.node); - } - addTitleSegment(parts, navModel.main); - } - - parts.push(Branding.AppTitle); + // Override `Home` with the custom brand title + parts[parts.length - 1] = Branding.AppTitle; document.title = parts.join(' - '); - }, [navModel, pageNav]); -} - -function addTitleSegment(parts: string[], node: NavModelItem) { - if (!node.hideFromBreadcrumbs) { - parts.push(node.text); - } + }, [homeNav, navModel, pageNav]); } diff --git a/public/app/core/components/PageNew/Page.test.tsx b/public/app/core/components/PageNew/Page.test.tsx index 4ea9862923b..66556318fa1 100644 --- a/public/app/core/components/PageNew/Page.test.tsx +++ b/public/app/core/components/PageNew/Page.test.tsx @@ -6,6 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { configureStore } from 'app/store/configureStore'; import { PageProps } from '../Page/types'; @@ -19,9 +20,12 @@ const pageNav: NavModelItem = { { text: 'pageNav child2', url: '2' }, ], }; - const setup = (props: Partial) => { config.bootData.navTree = [ + { + id: HOME_NAV_ID, + text: 'Home', + }, { text: 'Section name', id: 'section', @@ -92,7 +96,7 @@ describe('Render', () => { it('should update document title', async () => { setup({ navId: 'child1', pageNav }); - expect(document.title).toBe('pageNav child1 - pageNav title - Child1 - Section name - Grafana'); + expect(document.title).toBe('pageNav title - Child1 - Section name - Grafana'); }); it('should not include hideFromBreadcrumb nodes in title', async () => { diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index 4e0cd8dff93..7daa5ab05c4 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -83,7 +83,7 @@ const mockPreferences: UserPreferencesDTO = { queryHistory: { homeTab: '', }, - locale: '', + language: '', }; const mockPrefsPatch = jest.fn(); @@ -152,7 +152,7 @@ describe('SharedPreferences', () => { expect(weekSelect).toHaveTextContent('Monday'); }); - it('renders the locale preference', async () => { + it('renders the language preference', async () => { const weekSelect = getSelectParent(screen.getByLabelText(/language/i)); expect(weekSelect).toHaveTextContent('Default'); }); @@ -174,7 +174,7 @@ describe('SharedPreferences', () => { queryHistory: { homeTab: '', }, - locale: 'fr-FR', + language: 'fr-FR', }); }); @@ -196,7 +196,7 @@ describe('SharedPreferences', () => { queryHistory: { homeTab: '', }, - locale: '', + language: '', }); }); diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 5254a210938..f82808a350e 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -19,13 +19,14 @@ import { } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { t, Trans } from 'app/core/internationalization'; -import { LOCALES } from 'app/core/internationalization/constants'; +import { LANGUAGES } from 'app/core/internationalization/constants'; import { PreferencesService } from 'app/core/services/PreferencesService'; import { UserPreferencesDTO } from 'app/types'; export interface Props { resourceUri: string; disabled?: boolean; + onConfirm?: () => Promise; } export type State = UserPreferencesDTO; @@ -37,7 +38,7 @@ const themes: SelectableValue[] = [ ]; function getLanguageOptions(): Array> { - const languageOptions = LOCALES.map((v) => ({ + const languageOptions = LANGUAGES.map((v) => ({ value: v.code, label: v.name, })); @@ -66,7 +67,7 @@ export class SharedPreferences extends PureComponent { theme: '', timezone: '', weekStart: '', - locale: '', + language: '', queryHistory: { homeTab: '' }, }; } @@ -79,15 +80,19 @@ export class SharedPreferences extends PureComponent { theme: prefs.theme, timezone: prefs.timezone, weekStart: prefs.weekStart, - locale: prefs.locale, + language: prefs.language, queryHistory: prefs.queryHistory, }); } onSubmitForm = async () => { - const { homeDashboardUID, theme, timezone, weekStart, locale, queryHistory } = this.state; - await this.service.update({ homeDashboardUID, theme, timezone, weekStart, locale, queryHistory }); - window.location.reload(); + const confirmationResult = this.props.onConfirm ? await this.props.onConfirm() : true; + + if (confirmationResult) { + const { homeDashboardUID, theme, timezone, weekStart, language, queryHistory } = this.state; + await this.service.update({ homeDashboardUID, theme, timezone, weekStart, language, queryHistory }); + window.location.reload(); + } }; onThemeChanged = (value: string) => { @@ -109,12 +114,12 @@ export class SharedPreferences extends PureComponent { this.setState({ homeDashboardUID: dashboardUID }); }; - onLocaleChanged = (locale: string) => { - this.setState({ locale }); + onLanguageChanged = (language: string) => { + this.setState({ language }); }; render() { - const { theme, timezone, weekStart, homeDashboardUID, locale } = this.state; + const { theme, timezone, weekStart, homeDashboardUID, language } = this.state; const { disabled } = this.props; const styles = getStyles(); const languages = getLanguageOptions(); @@ -182,14 +187,14 @@ export class SharedPreferences extends PureComponent { Language - + } data-testid="User preferences language drop down" > + + + ); +}; + +export const MINUTE = '1m'; + type Props = { existing?: RuleWithLocation; }; @@ -37,6 +77,7 @@ export const AlertRuleForm: FC = ({ existing }) => { const notifyApp = useAppNotification(); const [queryParams] = useQueryParams(); const [showEditYaml, setShowEditYaml] = useState(false); + const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? MINUTE); const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list'; const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -51,8 +92,9 @@ export const AlertRuleForm: FC = ({ existing }) => { condition: 'C', ...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}), type: RuleFormType.grafana, + evaluateEvery: evaluateEvery, }; - }, [existing, queryParams]); + }, [existing, queryParams, evaluateEvery]); const formAPI = useForm({ mode: 'onSubmit', @@ -87,6 +129,8 @@ export const AlertRuleForm: FC = ({ existing }) => { }, existing, redirectOnSave: exitOnSave ? returnTo : undefined, + initialAlertRuleName: defaultValues.name, + evaluateEvery: evaluateEvery, }) ); }; @@ -160,11 +204,20 @@ export const AlertRuleForm: FC = ({ existing }) => {
    + {showStep2 && ( <> - {type === RuleFormType.grafana ? : } - + {type === RuleFormType.grafana ? ( + + ) : ( + + )} + )} @@ -222,5 +275,12 @@ const getStyles = (theme: GrafanaTheme2) => { flex-direction: row; justify-content: flex-start; `, + formInput: css` + width: 275px; + + & + & { + margin-left: ${theme.spacing(3)}; + } + `, }; }; diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx new file mode 100644 index 00000000000..0b40d78d138 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx @@ -0,0 +1,276 @@ +import { findByText, findByTitle, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { Provider } from 'react-redux'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { DashboardDTO } from '../../../../../types'; +import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types'; +import { mockStore } from '../../mocks'; +import { RuleFormValues } from '../../types/rule-form'; +import { Annotation } from '../../utils/constants'; +import { getDefaultFormValues } from '../../utils/rule-form'; + +import 'whatwg-fetch'; + +import AnnotationsField from './AnnotationsField'; + +// To get anything displayed inside the Autosize component we need to mock it +// Ref https://github.com/bvaughn/react-window/issues/454#issuecomment-646031139 +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) => + children({ height: 500, width: 330 }) +); + +const ui = { + setDashboardButton: byRole('button', { name: 'Set dashboard and panel' }), + annotationKeys: byTestId('annotation-key-', { exact: false }), + annotationValues: byTestId('annotation-value-', { exact: false }), + dashboardPicker: { + dialog: byRole('dialog'), + heading: byRole('heading', { name: 'Select dashboard and panel' }), + confirmButton: byRole('button', { name: 'Confirm' }), + }, +} as const; + +const server = setupServer(); + +beforeAll(() => { + setBackendSrv(backendSrv); + server.listen({ onUnhandledRequest: 'error' }); +}); + +beforeEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +function FormWrapper({ formValues }: { formValues?: Partial }) { + const store = mockStore(() => null); + const formApi = useForm({ defaultValues: { ...getDefaultFormValues(), ...formValues } }); + + return ( + + + + + + ); +} + +describe('AnnotationsField', function () { + it('should display default list of annotations', function () { + render(); + + const annotationElements = ui.annotationKeys.getAll(); + + expect(annotationElements).toHaveLength(3); + expect(annotationElements[0]).toHaveTextContent('Summary'); + expect(annotationElements[1]).toHaveTextContent('Description'); + expect(annotationElements[2]).toHaveTextContent('Runbook URL'); + }); + + describe('Dashboard and panel picker', function () { + it('should display dashboard and panel selector when select button clicked', async function () { + mockSearchResponse([]); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + + expect(ui.dashboardPicker.dialog.get()).toBeInTheDocument(); + expect(ui.dashboardPicker.heading.get()).toBeInTheDocument(); + }); + + it('should enable Confirm button only when dashboard and panel selected', async function () { + mockSearchResponse([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + ]); + + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [ + { id: 1, title: 'First panel' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled(); + + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard')); + expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled(); + + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'First panel')); + expect(ui.dashboardPicker.confirmButton.get()).toBeEnabled(); + }); + + it('should add selected dashboard and panel as annotations', async function () { + mockSearchResponse([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + ]); + + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [ + { id: 1, title: 'First panel' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard')); + + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Second panel')); + + await user.click(ui.dashboardPicker.confirmButton.get()); + + const annotationKeyElements = ui.annotationKeys.getAll(); + const annotationValueElements = ui.annotationValues.getAll(); + + expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument(); + + expect(annotationKeyElements).toHaveLength(2); + expect(annotationValueElements).toHaveLength(2); + + expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID'); + expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid'); + + expect(annotationKeyElements[1]).toHaveTextContent('Panel ID'); + expect(annotationValueElements[1]).toHaveTextContent('2'); + }); + + // this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item + // to trigger "handleDashboardChange" – skipping it for now but has been manually tested. + it.skip('should update existing dashboard and panel identifies', async function () { + mockSearchResponse([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + mockDashboardSearchItem({ + title: 'My other dashboard', + uid: 'dash-other-uid', + type: DashboardSearchItemType.DashDB, + }), + ]); + + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [ + { id: 1, title: 'First panel' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My other dashboard', + uid: 'dash-other-uid', + panels: [{ id: 3, title: 'Third panel' }], + }) + ); + + const user = userEvent.setup(); + + render( + + ); + + let annotationValueElements = ui.annotationValues.getAll(); + expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid'); + expect(annotationValueElements[1]).toHaveTextContent('1'); + + await user.click(ui.setDashboardButton.get()); + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My other dashboard')); + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Third panel')); + await user.click(ui.dashboardPicker.confirmButton.get()); + + expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument(); + + const annotationKeyElements = ui.annotationKeys.getAll(); + annotationValueElements = ui.annotationValues.getAll(); + + expect(annotationKeyElements).toHaveLength(2); + expect(annotationValueElements).toHaveLength(2); + + expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID'); + expect(annotationValueElements[0]).toHaveTextContent('dash-other-uid'); + + expect(annotationKeyElements[1]).toHaveTextContent('Panel ID'); + expect(annotationValueElements[1]).toHaveTextContent('3'); + }); + }); +}); + +function mockSearchResponse(searchResult: DashboardSearchItem[]) { + server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json(searchResult)))); +} + +function mockGetDashboardResponse(dashboard: DashboardDTO) { + server.use( + rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) => + res(ctx.json(dashboard)) + ) + ); +} + +function mockDashboardSearchItem(searchItem: Partial) { + return { + title: '', + uid: '', + type: DashboardSearchItemType.DashDB, + url: '', + uri: '', + items: [], + tags: [], + isStarred: false, + ...searchItem, + }; +} + +function mockDashboardDto(dashboard: Partial) { + return { + dashboard: { + title: '', + uid: '', + templating: { list: [] }, + panels: [], + ...dashboard, + }, + meta: {}, + }; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index 774f1bb4506..58df2ededd9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -1,21 +1,29 @@ import { css, cx } from '@emotion/css'; +import produce from 'immer'; import React, { useCallback } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; +import { Annotation } from '../../utils/constants'; import { AnnotationKeyInput } from './AnnotationKeyInput'; +import { DashboardPicker } from './DashboardPicker'; const AnnotationsField = () => { const styles = useStyles2(getStyles); + const [showPanelSelector, setShowPanelSelector] = useToggle(false); + const { control, register, watch, formState: { errors }, + setValue, } = useFormContext(); const annotations = watch('annotations'); @@ -26,6 +34,31 @@ const AnnotationsField = () => { const { fields, append, remove } = useFieldArray({ control, name: 'annotations' }); + const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value; + const selectedPanelId = annotations.find((annotation) => annotation.key === Annotation.panelID)?.value; + + const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => { + const updatedAnnotations = produce(annotations, (draft) => { + const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID); + const panelAnnotation = draft.find((a) => a.key === Annotation.panelID); + + if (dashboardAnnotation) { + dashboardAnnotation.value = dashboardUid; + } else { + draft.push({ key: Annotation.dashboardUID, value: dashboardUid }); + } + + if (panelAnnotation) { + panelAnnotation.value = panelId; + } else { + draft.push({ key: Annotation.panelID, value: panelId }); + } + }); + + setValue('annotations', updatedAnnotations); + setShowPanelSelector(false); + }; + return ( <> @@ -81,17 +114,31 @@ const AnnotationsField = () => {
    ); })} - + + + + + {showPanelSelector && ( + setShowPanelSelector(false)} + /> + )}
    ); @@ -99,7 +146,7 @@ const AnnotationsField = () => { const getStyles = (theme: GrafanaTheme2) => ({ annotationValueInput: css` - width: 426px; + width: 394px; `, textarea: css` height: 76px; diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx index fbe226a9c6c..c74eff45c59 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx @@ -28,7 +28,7 @@ export const CloudEvaluationBehavior = () => { } return ( - +
    diff --git a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx new file mode 100644 index 00000000000..ab0fd01aa52 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx @@ -0,0 +1,280 @@ +import { css, cx } from '@emotion/css'; +import React, { CSSProperties, useCallback, useMemo, useState } from 'react'; +import { useDebounce } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { FilterInput, LoadingPlaceholder, useStyles2, Icon, Modal, Button, Alert } from '@grafana/ui'; + +import { dashboardApi } from '../../api/dashboardApi'; + +export interface PanelDTO { + id: number; + title?: string; +} + +function panelSort(a: PanelDTO, b: PanelDTO) { + if (a.title && b.title) { + return a.title.localeCompare(b.title); + } + if (a.title && !b.title) { + return 1; + } else if (!a.title && b.title) { + return -1; + } + + return 0; +} + +interface DashboardPickerProps { + isOpen: boolean; + dashboardUid?: string | undefined; + panelId?: string | undefined; + onChange: (dashboardUid: string, panelId: string) => void; + onDismiss: () => void; +} + +export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDismiss }: DashboardPickerProps) => { + const styles = useStyles2(getPickerStyles); + + const [selectedDashboardUid, setSelectedDashboardUid] = useState(dashboardUid); + const [selectedPanelId, setSelectedPanelId] = useState(panelId); + + const [dashboardFilter, setDashboardFilter] = useState(''); + const [debouncedDashboardFilter, setDebouncedDashboardFilter] = useState(''); + + const [panelFilter, setPanelFilter] = useState(''); + const { useSearchQuery, useDashboardQuery } = dashboardApi; + + const { currentData: filteredDashboards = [], isFetching: isDashSearchFetching } = useSearchQuery({ + query: debouncedDashboardFilter, + }); + const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery( + { uid: selectedDashboardUid ?? '' }, + { skip: !selectedDashboardUid } + ); + + const handleDashboardChange = useCallback((dashboardUid: string) => { + setSelectedDashboardUid(dashboardUid); + setSelectedPanelId(undefined); + }, []); + + const filteredPanels = + dashboardResult?.dashboard?.panels + ?.filter((panel): panel is PanelDTO => typeof panel.id === 'number') + ?.filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase())) + .sort(panelSort) ?? []; + + const currentPanel = dashboardResult?.dashboard?.panels?.find((panel) => panel.id.toString() === selectedPanelId); + + const selectedDashboardIndex = useMemo(() => { + return filteredDashboards.map((dashboard) => dashboard.uid).indexOf(selectedDashboardUid ?? ''); + }, [filteredDashboards, selectedDashboardUid]); + + const isDefaultSelection = dashboardUid && dashboardUid === selectedDashboardUid; + const selectedDashboardIsInPageResult = selectedDashboardIndex >= 0; + + const scrollToItem = useCallback( + (node) => { + const canScroll = selectedDashboardIndex >= 0; + + if (isDefaultSelection && canScroll) { + node?.scrollToItem(selectedDashboardIndex, 'smart'); + } + }, + [isDefaultSelection, selectedDashboardIndex] + ); + + useDebounce( + () => { + setDebouncedDashboardFilter(dashboardFilter); + }, + 500, + [dashboardFilter] + ); + + const DashboardRow = ({ index, style }: { index: number; style?: CSSProperties }) => { + const dashboard = filteredDashboards[index]; + const isSelected = selectedDashboardUid === dashboard.uid; + + return ( +
    handleDashboardChange(dashboard.uid)} + > +
    {dashboard.title}
    +
    + {dashboard.folderTitle ?? 'General'} +
    +
    + ); + }; + + const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => { + const panel = filteredPanels[index]; + const isSelected = selectedPanelId === panel.id.toString(); + + return ( +
    setSelectedPanelId(panel.id.toString())} + > + {panel.title || ''} +
    + ); + }; + + return ( + + {/* This alert shows if the selected dashboard is not found in the first page of dashboards */} + {!selectedDashboardIsInPageResult && dashboardUid && ( + +
    + Dashboard: {dashboardResult?.dashboard.title} ({dashboardResult?.dashboard.uid}) in folder{' '} + {dashboardResult?.meta.folderTitle ?? 'General'} +
    + {Boolean(currentPanel) && ( +
    + Panel: {currentPanel.title} ({currentPanel.id}) +
    + )} +
    + )} +
    + + + +
    + {isDashSearchFetching && ( + + )} + + {!isDashSearchFetching && ( + + {({ height, width }) => ( + + {DashboardRow} + + )} + + )} +
    + +
    + {!dashboardUid && !isDashboardFetching &&
    Select a dashboard to get a list of available panels
    } + {isDashboardFetching && ( + + )} + + {!isDashboardFetching && ( + + {({ width, height }) => ( + + {PanelRow} + + )} + + )} +
    +
    + + + + +
    + ); +}; + +const getPickerStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: min-content auto; + gap: ${theme.spacing(2)}; + flex: 1; + `, + column: css` + flex: 1 1 auto; + `, + dashboardTitle: css` + height: 22px; + font-weight: ${theme.typography.fontWeightBold}; + `, + dashboardFolder: css` + height: 20px; + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; + display: flex; + flex-direction: row; + justify-content: flex-start; + column-gap: ${theme.spacing(1)}; + align-items: center; + `, + row: css` + padding: ${theme.spacing(0.5)}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + border: 2px solid transparent; + `, + rowSelected: css` + border-color: ${theme.colors.primary.border}; + `, + rowOdd: css` + background-color: ${theme.colors.background.secondary}; + `, + loadingPlaceholder: css` + height: 100%; + display: flex; + justify-content: center; + align-items: center; + `, + modal: css` + height: 100%; + `, + modalContent: css` + flex: 1; + display: flex; + flex-direction: column; + `, + modalAlert: css` + flex-grow: 0; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx index b53405ca357..4c6bcf2adae 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx @@ -1,54 +1,24 @@ -import { css } from '@emotion/css'; -import classNames from 'classnames'; -import React, { useCallback } from 'react'; +import React from 'react'; import { useFormContext } from 'react-hook-form'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Stack } from '@grafana/experimental'; -import { useStyles2, Field, Input, InputControl, Label, Tooltip, Icon } from '@grafana/ui'; -import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker'; -import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { AccessControlAction } from 'app/types'; - -import { RuleForm, RuleFormType, RuleFormValues } from '../../types/rule-form'; +import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import AnnotationsField from './AnnotationsField'; import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { RuleEditorSection } from './RuleEditorSection'; -import { RuleFolderPicker, Folder, containsSlashes } from './RuleFolderPicker'; -import { checkForPathSeparator } from './util'; -const recordingRuleNameValidationPattern = { - message: - 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.', - value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/, -}; - -interface DetailsStepProps { - initialFolder: RuleForm | null; -} - -export const DetailsStep = ({ initialFolder }: DetailsStepProps) => { - const { - register, - watch, - formState: { errors }, - } = useFormContext(); - - const styles = useStyles2(getStyles); +export function DetailsStep() { + const { watch } = useFormContext(); const ruleFormType = watch('type'); const dataSourceName = watch('dataSourceName'); const type = watch('type'); - const folderFilter = useRuleFolderFilter(initialFolder); - return ( { : 'Write a summary and add labels to help you better manage your alerts' } > - - { - // we use the alert rule name as the "groupname" for Grafana managed alerts, so we can't allow path separators - if (ruleFormType === RuleFormType.grafana) { - return checkForPathSeparator(value); - } - - return true; - }, - }, - })} - /> - - {(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && dataSourceName && } - {ruleFormType === RuleFormType.grafana && ( -
    - - - Folder - - Each folder has unique folder permission. When you store multiple rules in a folder, the folder - access permissions get assigned to the rules. -
    - } - > - - - - - } - className={styles.formInput} - error={errors.folder?.message} - invalid={!!errors.folder?.message} - data-testid="folder-picker" - > - ( - - )} - name="folder" - rules={{ - required: { value: true, message: 'Please select a folder' }, - validate: { - pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title), - }, - }} - /> -
    - - - -
    - )} {type !== RuleFormType.cloudRecording && }
    ); -}; - -const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => { - const isSearchHitAvailable = useCallback( - (hit: DashboardSearchHit) => { - const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders; - - const canCreateRuleInFolder = contextSrv.hasAccessInMetadata( - AccessControlAction.AlertingRuleCreate, - hit, - rbacDisabledFallback - ); - - const canUpdateInCurrentFolder = - existingRuleForm && - hit.folderId === existingRuleForm.id && - contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback); - return canCreateRuleInFolder || canUpdateInCurrentFolder; - }, - [existingRuleForm] - ); - - return useCallback( - (folderHits) => - folderHits - .filter(isSearchHitAvailable) - .filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')), - [isSearchHitAvailable] - ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - alignBaseline: css` - align-items: baseline; - margin-bottom: ${theme.spacing(3)}; - `, - formInput: css` - width: 275px; - - & + & { - margin-left: ${theme.spacing(3)}; - } - `, - flexRow: css` - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: end; - `, -}); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx new file mode 100644 index 00000000000..6b9aee47954 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -0,0 +1,229 @@ +import { css } from '@emotion/css'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Field, InputControl, Label, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker'; +import { contextSrv } from 'app/core/core'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { AccessControlAction, useDispatch } from 'app/types'; +import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions'; +import { RuleForm, RuleFormValues } from '../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { InfoIcon } from '../InfoIcon'; + +import { getIntervalForGroup } from './GrafanaEvaluationBehavior'; +import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker'; +import { SelectWithAdd } from './SelectWIthAdd'; +import { checkForPathSeparator } from './util'; + +const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undefined, folderName: string) => { + const groupOptions = useMemo(() => { + const groupsForFolderResult: Array> = groupfoldersForGrafana + ? groupfoldersForGrafana[folderName] ?? [] + : []; + return groupsForFolderResult.map((group) => group.name); + }, [groupfoldersForGrafana, folderName]); + + return groupOptions; +}; + +function mapGroupsToOptions(groups: string[]): Array> { + return groups.map((group) => ({ label: group, value: group })); +} +interface FolderAndGroupProps { + initialFolder: RuleForm | null; +} + +export const useGetGroupOptionsFromFolder = (folderTilte: string) => { + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + + const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; + + const groupOptions: Array> = mapGroupsToOptions( + useGetGroups(groupfoldersForGrafana?.result, folderTilte) + ); + const groupsForFolder = groupfoldersForGrafana?.result; + return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading }; +}; + +const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => { + const isSearchHitAvailable = useCallback( + (hit: DashboardSearchHit) => { + const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders; + + const canCreateRuleInFolder = contextSrv.hasAccessInMetadata( + AccessControlAction.AlertingRuleCreate, + hit, + rbacDisabledFallback + ); + + const canUpdateInCurrentFolder = + existingRuleForm && + hit.folderId === existingRuleForm.id && + contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback); + return canCreateRuleInFolder || canUpdateInCurrentFolder; + }, + [existingRuleForm] + ); + + return useCallback( + (folderHits) => + folderHits + .filter(isSearchHitAvailable) + .filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')), + [isSearchHitAvailable] + ); +}; + +export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) { + const { + formState: { errors }, + watch, + control, + } = useFormContext(); + + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const folderFilter = useRuleFolderFilter(initialFolder); + const [isAddingGroup, setIsAddingGroup] = useState(false); + + const folder = watch('folder'); + const group = watch('group'); + const [selectedGroup, setSelectedGroup] = useState(group); + const initialRender = useRef(true); + + const { groupOptions, groupsForFolder, loading } = useGetGroupOptionsFromFolder(folder?.title ?? ''); + + useEffect(() => setSelectedGroup(group), [group, setSelectedGroup]); + + useEffect(() => { + dispatch(fetchRulerRulesIfNotFetchedYet(GRAFANA_RULES_SOURCE_NAME)); + }, [dispatch]); + + const resetGroup = useCallback(() => { + if (group && !initialRender.current && folder?.title) { + setSelectedGroup(''); + } + initialRender.current = false; + }, [group, folder?.title]); + + const groupIsInGroupOptions = useCallback( + (group_: string) => { + return groupOptions.includes((groupInList: SelectableValue) => groupInList.label === group_); + }, + [groupOptions] + ); + + return ( +
    + + + Folder + + + + } + className={styles.formInput} + error={errors.folder?.message} + invalid={!!errors.folder?.message} + data-testid="folder-picker" + > + ( + { + field.onChange({ title, uid }); + if (!groupIsInGroupOptions(selectedGroup)) { + setIsAddingGroup(false); + resetGroup(); + } + }} + /> + )} + name="folder" + rules={{ + required: { value: true, message: 'Select a folder' }, + validate: { + pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title), + }, + }} + /> + + + + + loading ? ( + + ) : ( + ) => + `${option.label} (${getIntervalForGroup(groupsForFolder, option.label ?? '', folder?.title ?? '')})` + } + value={selectedGroup} + custom={isAddingGroup} + onCustomChange={(custom: boolean) => setIsAddingGroup(custom)} + placeholder="Evaluation group name" + onChange={(value: string) => { + field.onChange(value); + setSelectedGroup(value); + }} + /> + ) + } + name="group" + control={control} + rules={{ + required: { value: true, message: 'Must enter a group name' }, + validate: { + pathSeparator: (group_: string) => checkForPathSeparator(group_), + }, + }} + /> + +
    + ); +} +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + flex-direction: row; + align-items: baseline; + max-width: ${theme.breakpoints.values.sm}px; + justify-content: space-between; + `, + formInput: css` + width: 275px; + & + & { + margin-left: ${theme.spacing(3)}; + } + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 5e8e590db6b..2e93419ae59 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -1,22 +1,40 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { RegisterOptions, useFormContext } from 'react-hook-form'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Button, Card, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui'; +import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { RuleFormValues } from '../../types/rule-form'; -import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; +import { logInfo, LogMessages } from '../../Analytics'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { RuleForm, RuleFormValues } from '../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { parsePrometheusDuration } from '../../utils/time'; import { CollapseToggle } from '../CollapseToggle'; -import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; +import { EditCloudGroupModal } from '../rules/EditRuleGroupModal'; +import { MINUTE } from './AlertRuleForm'; +import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { RuleEditorSection } from './RuleEditorSection'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds -export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ +export const getIntervalForGroup = ( + rulerRules: RulerRulesConfigDTO | null | undefined, + group: string, + folder: string +) => { + const folderObj: Array> = rulerRules ? rulerRules[folder] : []; + const groupObj = folderObj?.find((rule) => rule.name === group); + + const interval = groupObj?.interval ?? MINUTE; + return interval; +}; + +const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ required: { value: true, message: 'Required.', @@ -51,90 +69,162 @@ export const forValidationOptions = (evaluateEvery: string): RegisterOptions => }, }); -export const evaluateEveryValidationOptions: RegisterOptions = { - required: { - value: true, - message: 'Required.', - }, - validate: (value: string) => { - try { - const duration = parsePrometheusDuration(value); +const useIsNewGroup = (folder: string, group: string) => { + const { groupOptions } = useGetGroupOptionsFromFolder(folder); - if (duration < MIN_TIME_RANGE_STEP_S * 1000) { - return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; - } - - if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { - return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; - } - - return true; - } catch (error) { - return error instanceof Error ? error.message : 'Failed to parse duration'; - } - }, + const groupIsInGroupOptions = useCallback( + (group_: string) => groupOptions.some((groupInList: SelectableValue) => groupInList.label === group_), + [groupOptions] + ); + return !groupIsInGroupOptions(group); }; -export const GrafanaEvaluationBehavior = () => { +function FolderGroupAndEvaluationInterval({ + initialFolder, + evaluateEvery, + setEvaluateEvery, +}: { + initialFolder: RuleForm | null; + evaluateEvery: string; + setEvaluateEvery: (value: string) => void; +}) { + const styles = useStyles2(getStyles); + const { watch } = useFormContext(); + const [isEditingGroup, setIsEditingGroup] = useState(false); + + const group = watch('group'); + const folder = watch('folder'); + + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; + + const isNewGroup = useIsNewGroup(folder?.title ?? '', group); + + useEffect(() => { + group && + folder && + setEvaluateEvery(getIntervalForGroup(groupfoldersForGrafana?.result, group, folder?.title ?? '')); + }, [group, folder, groupfoldersForGrafana?.result, setEvaluateEvery]); + + const closeEditGroupModal = (saved = false) => { + if (!saved) { + logInfo(LogMessages.leavingRuleGroupEdit); + } + setIsEditingGroup(false); + }; + + const onOpenEditGroupModal = () => setIsEditingGroup(true); + + const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folder || !group; + + return ( +
    + + {isEditingGroup && ( + closeEditGroupModal()} + folderAndGroupReadOnly + /> + )} + {folder && group && ( + + Evaluation behavior + + +
    + {`Alert rules in the `} {group} group are evaluated every{' '} + {evaluateEvery}. +
    + +
    + {!isNewGroup && ( +
    + {`Evaluation group interval applies to every rule within a group. It overwrites intervals defined for existing alert rules.`} +
    + )} +
    +
    +
    + + + {isNewGroup && ( +
    + {`To edit the evaluation group interval, save the alert rule.`} +
    + )} + +
    +
    +
    + )} +
    + ); +} + +function ForInput({ evaluateEvery }: { evaluateEvery: string }) { const styles = useStyles2(getStyles); - const [showErrorHandling, setShowErrorHandling] = useState(false); const { register, formState: { errors }, - watch, } = useFormContext(); - const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery')); - - const evaluateEveryId = 'eval-every-input'; const evaluateForId = 'eval-for-input'; return ( - // TODO remove "and alert condition" for recording rules - - + -
    - - Evaluate every - - - - - - - for - - - - -
    + for +
    + + - {exceedsGlobalEvaluationLimit && } + + ); +} + +export function GrafanaEvaluationBehavior({ + initialFolder, + evaluateEvery, + setEvaluateEvery, +}: { + initialFolder: RuleForm | null; + evaluateEvery: string; + setEvaluateEvery: (value: string) => void; +}) { + const styles = useStyles2(getStyles); + const [showErrorHandling, setShowErrorHandling] = useState(false); + + return ( + // TODO remove "and alert condition" for recording rules + + + + + setShowErrorHandling(!collapsed)} @@ -177,22 +267,34 @@ export const GrafanaEvaluationBehavior = () => { )} ); -}; +} const getStyles = (theme: GrafanaTheme2) => ({ inlineField: css` margin-bottom: 0; `, - flexRow: css` - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: flex-start; - `, collapseToggle: css` margin: ${theme.spacing(2, 0, 2, -1)}; `, - globalLimitValue: css` - font-weight: ${theme.typography.fontWeightBold}; + evaluateLabel: css` + align-self: left; + margin-right: ${theme.spacing(1)}; + `, + cardContainer: css` + max-width: ${theme.breakpoints.values.sm}px; + `, + intervalChangedLabel: css` + margin-bottom: ${theme.spacing(1)}; + `, + warningIcon: css` + justify-self: center; + margin-right: ${theme.spacing(1)}; + color: ${theme.colors.warning.text}; + `, + warningMessage: css` + color: ${theme.colors.warning.text}; + `, + bold: css` + font-weight: bold; `, }); diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index 8c9cedac09c..fc1ff41babb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -5,7 +5,7 @@ import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Card, Link, useStyles2, useTheme2 } from '@grafana/ui'; -import { RuleFormValues } from '../../types/rule-form'; +import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import LabelsField from './LabelsField'; @@ -15,14 +15,15 @@ export const NotificationsStep = () => { const [hideFlowChart, setHideFlowChart] = useState(false); const styles = useStyles2(getStyles); const theme = useTheme2(); + const { watch } = useFormContext(); - const { watch } = useFormContext(); + const type = watch('type'); const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; return ( diff --git a/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx b/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx index 351bb234a15..8a05c83cc0a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx @@ -15,6 +15,7 @@ interface Props { width?: number; disabled?: boolean; 'aria-label'?: string; + getOptionLabel?: ((item: SelectableValue) => React.ReactNode) | undefined; } export const SelectWithAdd: FC = ({ @@ -29,13 +30,12 @@ export const SelectWithAdd: FC = ({ disabled = false, addLabel = '+ Add new', 'aria-label': ariaLabel, + getOptionLabel, }) => { const [isCustom, setIsCustom] = useState(custom); useEffect(() => { - if (custom) { - setIsCustom(custom); - } + setIsCustom(custom); }, [custom]); const _options = useMemo( @@ -65,6 +65,7 @@ export const SelectWithAdd: FC = ({ value={value} className={className} placeholder={placeholder} + getOptionLabel={getOptionLabel} disabled={disabled} onChange={(val: SelectableValue) => { const value = val?.value; diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index d329a1d3de7..974635f8352 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -147,7 +147,7 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { }, [condition, queries, setValue]); return ( - + {/* This is the PromQL Editor for Cloud rules and recording rules */} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap index b8678183f40..abaaa52a6bd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap @@ -1,42 +1,42 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Query and expressions reducer should add a new expression 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", "refId": "A", }, - Object { + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [], + "query": { + "params": [], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", @@ -55,20 +55,20 @@ Object { `; exports[`Query and expressions reducer should add query 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", "refId": "A", }, - Object { + { "datasourceUid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d", - "model": Object { - "datasource": Object { + "model": { + "datasource": { "type": "prometheus", "uid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d", }, @@ -77,7 +77,7 @@ Object { }, "queryType": "", "refId": "B", - "relativeTimeRange": Object { + "relativeTimeRange": { "from": 600, "to": 0, }, @@ -87,25 +87,25 @@ Object { `; exports[`Query and expressions reducer should duplicate query 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", "refId": "A", }, - Object { + { "datasourceUid": "abc123", - "model": Object { + "model": { "hide": false, "refId": "B", }, "queryType": "", "refId": "B", - "relativeTimeRange": Object { + "relativeTimeRange": { "from": 600, "to": 0, }, @@ -115,11 +115,11 @@ Object { `; exports[`Query and expressions reducer should remove an expression or alert query 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", @@ -130,44 +130,44 @@ Object { `; exports[`Query and expressions reducer should rewire expressions 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", "refId": "A", }, - Object { + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [ + "query": { + "params": [ "C", ], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", @@ -184,36 +184,36 @@ Object { `; exports[`Query and expressions reducer should set data queries 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [ + "query": { + "params": [ "A", ], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", @@ -230,36 +230,36 @@ Object { `; exports[`Query and expressions reducer should update an expression 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [ + "query": { + "params": [ "A", ], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", @@ -276,44 +276,44 @@ Object { `; exports[`Query and expressions reducer should update an expression refId and rewire expressions 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "C", }, "queryType": "query", "refId": "C", }, - Object { + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [ + "query": { + "params": [ "C", ], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", @@ -330,42 +330,42 @@ Object { `; exports[`Query and expressions reducer should update expression type 1`] = ` -Object { - "queries": Array [ - Object { +{ + "queries": [ + { "datasourceUid": "abc123", - "model": Object { + "model": { "refId": "A", }, "queryType": "query", "refId": "A", }, - Object { + { "datasourceUid": "-100", - "model": Object { - "conditions": Array [ - Object { - "evaluator": Object { - "params": Array [ + "model": { + "conditions": [ + { + "evaluator": { + "params": [ 0, 0, ], "type": "gt", }, - "operator": Object { + "operator": { "type": "and", }, - "query": Object { - "params": Array [], + "query": { + "params": [], }, - "reducer": Object { - "params": Array [], + "reducer": { + "params": [], "type": "avg", }, "type": "query", }, ], - "datasource": Object { + "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__", diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 2dafb3caf12..24376df0e68 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -14,7 +14,7 @@ import { RulerRulesConfigDTO, RulerRuleGroupDTO, RulerRuleDTO } from 'app/types/ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; -import { getRulesSourceName } from '../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; import { isAlertingRulerRule, isGrafanaRulerRule } from '../../utils/rules'; import { parsePrometheusDuration } from '../../utils/time'; @@ -186,10 +186,20 @@ export const RulesForGroupTable = ({ ); }; -interface ModalProps { +interface CombinedGroupAndNameSpace { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; +} +interface GroupAndNameSpaceNames { + namespace: string; + group: string; +} +interface ModalProps { + nameSpaceAndGroup: CombinedGroupAndNameSpace | GroupAndNameSpaceNames; + sourceName: string; + groupInterval: string; onClose: (saved?: boolean) => void; + folderAndGroupReadOnly?: boolean; } interface FormValues { @@ -199,22 +209,42 @@ interface FormValues { } export function EditCloudGroupModal(props: ModalProps): React.ReactElement { - const { namespace, group, onClose } = props; + const { + nameSpaceAndGroup: { namespace, group }, + onClose, + groupInterval, + sourceName, + folderAndGroupReadOnly, + } = props; + const styles = useStyles2(getStyles); const dispatch = useDispatch(); const { loading, error, dispatched } = useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState; const notifyApp = useAppNotification(); - + const nameSpaceName = typeof namespace === 'string' ? namespace : namespace.name; + const groupName = typeof group === 'string' ? group : group.name; const defaultValues = useMemo( (): FormValues => ({ - namespaceName: namespace.name, - groupName: group.name, - groupInterval: group.interval ?? '', + namespaceName: nameSpaceName, + groupName: groupName, + groupInterval: groupInterval ?? '', }), - [namespace, group] + [nameSpaceName, groupName, groupInterval] ); + const isGrafanaManagedGroup = sourceName === GRAFANA_RULES_SOURCE_NAME; + const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; + const nameSpaceInfoIconLabelEditable = isGrafanaManagedGroup + ? 'Folder name can be updated to a non-existing folder name' + : 'Name space can be updated to a non-existing name space'; + const nameSpaceInfoIconLabelNonEditable = isGrafanaManagedGroup + ? 'Folder name can be updated in folder view' + : 'Name space can be updated folder view'; + + const spaceNameInfoIconLabel = folderAndGroupReadOnly + ? nameSpaceInfoIconLabelNonEditable + : nameSpaceInfoIconLabelEditable; // close modal if successfully saved useEffect(() => { if (dispatched && !loading && !error) { @@ -223,14 +253,13 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { }, [dispatched, loading, onClose, error]); useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState)); - const onSubmit = (values: FormValues) => { dispatch( updateLotexNamespaceAndGroupAction({ - rulesSourceName: getRulesSourceName(namespace.rulesSource), - groupName: group.name, + rulesSourceName: sourceName, + groupName: groupName, newGroupName: values.groupName, - namespaceName: namespace.name, + namespaceName: nameSpaceName, newNamespaceName: values.namespaceName, groupInterval: values.groupInterval || undefined, }) @@ -254,7 +283,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { }; const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - const groupfoldersForSource = rulerRuleRequests[getRulesSourceName(namespace.rulesSource)]; + const groupfoldersForSource = rulerRuleRequests[sourceName]; const evaluateEveryValidationOptions: RegisterOptions = { required: { @@ -273,7 +302,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; } if ( - rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, group.name, namespace.name, value).length === 0 + rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, groupName, nameSpaceName, value).length === 0 ) { return true; } else { @@ -289,7 +318,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { @@ -300,8 +329,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { label={ } @@ -310,6 +339,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { > Evaluation group - + {isGrafanaManagedGroup ? ( + + ) : ( + + )} } @@ -329,6 +363,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { > )} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index c963e732964..eec77d0658b 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -13,7 +13,7 @@ import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; import { deleteRulesGroupAction } from '../../state/actions'; import { useRulesAccess } from '../../utils/accessControlHooks'; -import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; +import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { makeFolderLink } from '../../utils/misc'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; @@ -224,7 +224,14 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll, {!isCollapsed && ( )} - {isEditingGroup && closeEditModal()} />} + {isEditingGroup && ( + closeEditModal()} + /> + )} {isReorderingGroup && ( setIsReorderingGroup(false)} /> )} diff --git a/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap b/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap index 6068c9caec1..f9396ab2149 100644 --- a/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`groupStateByLabels should group a list by labels 1`] = ` -Object { - "{cpu=0, type=cpu}": Array [ - Object { - "data": Object {}, +{ + "{cpu=0, type=cpu}": [ + { + "data": {}, "id": "1", "state": "alerting", "stringifiedLabels": "{cpu=0, type=cpu}", @@ -12,9 +12,9 @@ Object { "timestamp": 1658834395024, }, ], - "{cpu=1, type=cpu}": Array [ - Object { - "data": Object {}, + "{cpu=1, type=cpu}": [ + { + "data": {}, "id": "2", "state": "ok", "stringifiedLabels": "{cpu=1, type=cpu}", @@ -26,10 +26,10 @@ Object { `; exports[`groupStateByLabels should group a list by labels even if the alert rule name has {} 1`] = ` -Object { - "{cpu=0, type=cpu}": Array [ - Object { - "data": Object {}, +{ + "{cpu=0, type=cpu}": [ + { + "data": {}, "id": "1", "state": "alerting", "stringifiedLabels": "{cpu=0, type=cpu}", @@ -37,9 +37,9 @@ Object { "timestamp": 1658834395024, }, ], - "{cpu=1, type=cpu}": Array [ - Object { - "data": Object {}, + "{cpu=1, type=cpu}": [ + { + "data": {}, "id": "2", "state": "ok", "stringifiedLabels": "{cpu=1, type=cpu}", diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx index 9c0d44d652c..0f2c8d3f112 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx @@ -6,7 +6,7 @@ import { Provider } from 'react-redux'; import 'whatwg-fetch'; import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; @@ -17,12 +17,8 @@ import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector'; const server = setupServer(); -jest.mock('@grafana/runtime', () => ({ - ...(jest.requireActual('@grafana/runtime') as unknown as object), - getBackendSrv: () => backendSrv, -})); - beforeAll(() => { + setBackendSrv(backendSrv); server.listen({ onUnhandledRequest: 'error' }); }); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b3c34b512c7..b2ed0b227ed 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -443,10 +443,13 @@ export const saveRuleFormAction = createAsyncThunk( values, existing, redirectOnSave, + evaluateEvery, }: { values: RuleFormValues; existing?: RuleWithLocation; redirectOnSave?: string; + initialAlertRuleName?: string; + evaluateEvery: string; }, thunkAPI ): Promise => @@ -463,15 +466,18 @@ export const saveRuleFormAction = createAsyncThunk( if (!values.dataSourceName) { throw new Error('The Data source has not been defined.'); } + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName); const rulerClient = getRulerClient(rulerConfig); - identifier = await rulerClient.saveLotexRule(values, existing); + identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing); + await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName })); // in case of grafana managed } else if (type === RuleFormType.grafana) { const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); const rulerClient = getRulerClient(rulerConfig); - identifier = await rulerClient.saveGrafanaRule(values, existing); + identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing); + await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); } else { throw new Error('Unexpected rule form type'); } @@ -765,7 +771,9 @@ export const rulesInSameGroupHaveInvalidFor = ( return rulesSameGroup.filter((rule: RulerRuleDTO) => { const { forDuration } = getAlertInfo(rule, everyDuration); - return safeParseDurationstr(forDuration) < safeParseDurationstr(everyDuration); + const forNumber = safeParseDurationstr(forDuration); + const everyNumber = safeParseDurationstr(everyDuration); + return forNumber !== 0 && forNumber < everyNumber; }); }; diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 3a097ab778b..b87fc0c2e5c 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -27,7 +27,6 @@ export interface RuleFormValues { noDataState: GrafanaAlertStateDecision; execErrState: GrafanaAlertStateDecision; folder: RuleForm | null; - evaluateEvery: string; evaluateFor: string; // cortex / loki rules diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index 91d24e856b6..77658b56588 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -1,40 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values 1`] = ` -Object { - "annotations": Object { +{ + "annotations": { "description": "", "runbook_url": "", "summary": "", }, "for": "5m", - "grafana_alert": Object { + "grafana_alert": { "condition": "A", - "data": Array [], + "data": [], "exec_err_state": "Error", "no_data_state": "NoData", "title": "", }, - "labels": Object { + "labels": { "": "", }, } `; exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = ` -Object { - "annotations": Object { +{ + "annotations": { "description": "", "runbook_url": "", "summary": "", }, "for": "5m", - "grafana_alert": Object { + "grafana_alert": { "condition": "A", - "data": Array [ - Object { + "data": [ + { "datasourceUid": "dsuid", - "model": Object { + "model": { "expr": "", "instant": false, "range": true, @@ -42,7 +42,7 @@ Object { }, "queryType": "query", "refId": "A", - "relativeTimeRange": Object { + "relativeTimeRange": { "from": 900, "to": 1000, }, @@ -52,7 +52,7 @@ Object { "no_data_state": "NoData", "title": "", }, - "labels": Object { + "labels": { "": "", }, } diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 8abb1698cf7..1d62bf960cf 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -59,7 +59,6 @@ export const getDefaultFormValues = (): RuleFormValues => { condition: '', noDataState: GrafanaAlertStateDecision.NoData, execErrState: GrafanaAlertStateDecision.Error, - evaluateEvery: '1m', evaluateFor: '5m', // cortex / loki @@ -126,7 +125,6 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF type: RuleFormType.grafana, group: group.name, evaluateFor: rule.for || '0', - evaluateEvery: group.interval || defaultFormValues.evaluateEvery, noDataState: ga.no_data_state, execErrState: ga.exec_err_state, queries: ga.data, diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts index c7ed178ead0..ed5938020b8 100644 --- a/public/app/features/alerting/unified/utils/rulerClient.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -22,8 +22,8 @@ import { export interface RulerClient { findEditableRule(ruleIdentifier: RuleIdentifier): Promise; deleteRule(ruleWithLocation: RuleWithLocation): Promise; - saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; - saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; + saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; + saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; } export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { @@ -95,7 +95,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient }); }; - const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const saveLotexRule = async ( + values: RuleFormValues, + evaluateEvery: string, + existing?: RuleWithLocation + ): Promise => { const { dataSourceName, group, namespace } = values; const formRule = formValuesToRulerRuleDTO(values); if (dataSourceName && group && namespace) { @@ -116,6 +120,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient rules: freshExisting.group.rules.map((existingRule) => existingRule === freshExisting.rule ? formRule : existingRule ), + evaluateEvery: evaluateEvery, }; await setRulerRuleGroup(rulerConfig, namespace, payload); return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); @@ -143,8 +148,12 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient } }; - const saveGrafanaRule = async (values: RuleFormValues, existingRule?: RuleWithLocation): Promise => { - const { folder, group, evaluateEvery } = values; + const saveGrafanaRule = async ( + values: RuleFormValues, + evaluateEvery: string, + existingRule?: RuleWithLocation + ): Promise => { + const { folder, group } = values; if (!folder) { throw new Error('Folder must be specified'); } diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx index fd61e6683be..f1bcc796342 100644 --- a/public/app/features/api-keys/ApiKeysPage.test.tsx +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -1,12 +1,14 @@ import { render, screen, within } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; import React from 'react'; +import { Provider } from 'react-redux'; import { selectors } from '@grafana/e2e-selectors'; import { ApiKey, OrgRole } from 'app/types'; import { mockToolkitActionCreator } from '../../../test/core/redux/mocks'; import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; +import { configureStore } from '../../store/configureStore'; import { ApiKeysPageUnconnected, Props } from './ApiKeysPage'; import { getMultipleMockKeys } from './__mocks__/apiKeysMock'; @@ -22,6 +24,7 @@ jest.mock('app/core/core', () => { }); const setup = (propOverrides: Partial) => { + const store = configureStore(); const loadApiKeysMock = jest.fn(); const deleteApiKeyMock = jest.fn(); const migrateApiKeyMock = jest.fn(); @@ -54,9 +57,13 @@ const setup = (propOverrides: Partial) => { Object.assign(props, propOverrides); - const { rerender } = render(); + const { rerender } = render( + + + + ); return { - rerender, + rerender: (element: JSX.Element) => rerender({element}), props, loadApiKeysMock, setSearchQueryMock, diff --git a/public/app/features/commandPalette/actions/global.static.actions.ts b/public/app/features/commandPalette/actions/global.static.actions.ts index 4211a532ac3..7fb13a30a0f 100644 --- a/public/app/features/commandPalette/actions/global.static.actions.ts +++ b/public/app/features/commandPalette/actions/global.static.actions.ts @@ -33,6 +33,7 @@ export default (navBarTree: NavModelItem[]) => { name: 'Change theme...', keywords: 'interface color dark light', section: 'Preferences', + shortcut: ['c', 't'], }, { id: 'preferences/dark-theme', diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 1452dd272c5..11621cb2f4b 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -37,6 +37,7 @@ jest.mock('@grafana/runtime', () => ({ featureToggles: { newVariables: false, }, + angularSupportEnabled: true, }, })); diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 0ced7a34b85..5ffbef293d9 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -342,6 +342,7 @@ export const DashNav = React.memo((props) => { if (config.featureToggles.scenes) { buttons.push( locationService.push(`/scenes/dashboard/${dashboard.uid}`)} diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx index a510c4b0eb8..3d68416cfa7 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx @@ -2,6 +2,7 @@ import { within } from '@testing-library/dom'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -10,11 +11,13 @@ import { locationService, setAngularLoader, setDataSourceSrv } from '@grafana/ru import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { configureStore } from '../../../../store/configureStore'; import { DashboardModel } from '../../state/DashboardModel'; import { AnnotationsSettings } from './AnnotationsSettings'; function setup(dashboard: DashboardModel, editIndex?: number) { + const store = configureStore(); const sectionNav = { main: { text: 'Dashboard' }, node: { @@ -24,9 +27,11 @@ function setup(dashboard: DashboardModel, editIndex?: number) { return render( - - - + + + + + ); } diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx index e8c4aba49d1..227765f674d 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -10,6 +11,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { BackendSrv, setBackendSrv } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { configureStore } from '../../../../store/configureStore'; import { DashboardModel } from '../../state'; import { GeneralSettingsUnconnected as GeneralSettings, Props } from './GeneralSettings'; @@ -19,6 +21,7 @@ setBackendSrv({ } as unknown as BackendSrv); const setupTestContext = (options: Partial) => { + const store = configureStore(); const defaults: Props = { dashboard: new DashboardModel( { @@ -49,9 +52,11 @@ const setupTestContext = (options: Partial) => { const { rerender } = render( - - - + + + + + ); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index f3c1a51b72c..c6716812b13 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -2,6 +2,7 @@ import { within } from '@testing-library/dom'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -9,11 +10,13 @@ import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { configureStore } from '../../../../store/configureStore'; import { DashboardModel } from '../../state'; import { DashboardSettings } from './DashboardSettings'; function setup(dashboard: DashboardModel) { + const store = configureStore(); const sectionNav = { main: { text: 'Dashboard' }, node: { @@ -24,9 +27,11 @@ function setup(dashboard: DashboardModel) { // Need to use DashboardSettings here as it's responsible for fetching the state back from location return render( - - - + + + + + ); } diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx index 2ab45f6268d..9de7e2b0ff1 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -2,11 +2,13 @@ import { within } from '@testing-library/dom'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { configureStore } from '../../../../store/configureStore'; import { DashboardModel } from '../../state/DashboardModel'; import { historySrv } from '../VersionHistory/HistorySrv'; @@ -27,6 +29,7 @@ const queryByFullText = (text: string) => }); function setup() { + const store = configureStore(); const dashboard = new DashboardModel({ id: 74, version: 11, @@ -43,9 +46,11 @@ function setup() { return render( - - - + + + + + ); } diff --git a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.test.ts b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.test.ts index ccb5b7b5ff9..450b874685e 100644 --- a/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.test.ts +++ b/public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.test.ts @@ -30,38 +30,38 @@ describe('SupportSnapshotService', () => { const service = new SupportSnapshotService(panel); await service.buildDebugDashboard(); expect(service.state.snapshot.panels[0].targets[0]).toMatchInlineSnapshot(` - Object { - "datasource": Object { + { + "datasource": { "type": "grafana", "uid": "grafana", }, "queryType": "snapshot", "refId": "A", - "snapshot": Array [ - Object { - "data": Object { - "values": Array [ - Array [ + "snapshot": [ + { + "data": { + "values": [ + [ 1, 2, 3, ], - Array [ + [ 11, 22, 33, ], ], }, - "schema": Object { - "fields": Array [ - Object { - "config": Object {}, + "schema": { + "fields": [ + { + "config": {}, "name": "Time", "type": "time", }, - Object { - "config": Object {}, + { + "config": {}, "name": "Value", "type": "number", }, diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts index 86f37cb761a..bb7c385e2d7 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts @@ -23,14 +23,14 @@ describe('getPanelEditorTabs selector', () => { it('marks tab as active when tab param provided', () => { expect(getPanelEditorTabs('transform', { meta: { skipDataQuery: false } } as PanelPlugin)).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "active": false, "icon": "database", "id": "query", "text": "Query", }, - Object { + { "active": true, "icon": "process", "id": "transform", diff --git a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx index 9018ec6c026..2a5fa2a262e 100644 --- a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { ClipboardButton, CodeEditor, Modal } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; export interface ViewJsonModalProps { json: string; @@ -17,7 +18,7 @@ export function ViewJsonModal({ json, onDismiss }: ViewJsonModalProps): JSX.Elem - Copy to Clipboard + Copy to Clipboard diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index be37870e6a3..5dd77df63b7 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -13,6 +13,7 @@ import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; import { notifyApp } from 'app/core/actions'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types'; import { configureStore } from '../../../store/configureStore'; @@ -105,7 +106,10 @@ function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioCo setupFn = fn; }, mount: (propOverrides?: Partial) => { - config.bootData.navTree = [{ text: 'Dashboards', id: 'dashboards' }]; + config.bootData.navTree = [ + { text: 'Dashboards', id: 'dashboards' }, + { text: 'Home', id: HOME_NAV_ID }, + ]; const store = configureStore(); const props: Props = { @@ -114,7 +118,8 @@ function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioCo route: { routeName: DashboardRoutes.Normal } as any, }), navIndex: { - dashboards: { text: 'Dashboards' }, + dashboards: { text: 'Dashboards', id: 'dashboards', parentItem: { text: 'Home', id: HOME_NAV_ID } }, + [HOME_NAV_ID]: { text: 'Home', id: HOME_NAV_ID }, }, initPhase: DashboardInitPhase.NotStarted, initError: null, diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 3573aeeee31..f81bcfcb5e3 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -26,7 +26,7 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); -export type Props = GrafanaRouteComponentProps & +export type Props = GrafanaRouteComponentProps & ConnectedProps; export interface State { @@ -87,6 +87,7 @@ export class SoloPanelPage extends Component { notFound={this.state.notFound} panel={this.state.panel} panelId={this.getPanelId()} + timezone={this.props.queryParams.timezone} /> ); } @@ -95,9 +96,10 @@ export class SoloPanelPage extends Component { export interface SoloPanelProps extends State { dashboard: DashboardModel | null; panelId: number; + timezone?: string; } -export const SoloPanel = ({ dashboard, notFound, panel, panelId }: SoloPanelProps) => { +export const SoloPanel = ({ dashboard, notFound, panel, panelId, timezone }: SoloPanelProps) => { if (notFound) { return
    Panel with id {panelId} not found
    ; } @@ -123,6 +125,7 @@ export const SoloPanel = ({ dashboard, notFound, panel, panelId }: SoloPanelProp isEditing={false} isViewing={false} lazy={false} + timezone={timezone} /> ); }} diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index f6b6a0cf367..4f76e07a792 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -20,6 +20,7 @@ export interface OwnProps { width: number; height: number; lazy?: boolean; + timezone?: string; } const mapStateToProps = (state: StoreState, props: OwnProps) => { @@ -70,7 +71,7 @@ export class DashboardPanelUnconnected extends PureComponent { }; renderPanel = (isInView: boolean) => { - const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props; + const { dashboard, panel, isViewing, isEditing, width, height, plugin, timezone } = this.props; if (!plugin) { return null; @@ -102,6 +103,7 @@ export class DashboardPanelUnconnected extends PureComponent { width={width} height={height} onInstanceStateChange={this.onInstanceStateChange} + timezone={timezone} /> ); }; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index c296384eec0..bcd7ebbef26 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -61,6 +61,7 @@ export interface Props { width: number; height: number; onInstanceStateChange: (value: any) => void; + timezone?: string; } export interface State { @@ -521,6 +522,8 @@ export class PanelStateWrapper extends PureComponent { // Yes this is called ever render for a function that is triggered on every mouse move this.eventFilter.onlyLocal = dashboard.graphTooltip === 0; + const timeZone = this.props.timezone || this.props.dashboard.getTimezone(); + return ( <>
    @@ -531,7 +534,7 @@ export class PanelStateWrapper extends PureComponent { data={data} title={panel.title} timeRange={timeRange} - timeZone={this.props.dashboard.getTimezone()} + timeZone={timeZone} options={panelOptions} fieldConfig={panel.fieldConfig} transparent={panel.transparent} diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 11e32a26eeb..41f7ca91703 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -1,7 +1,9 @@ import { lastValueFrom } from 'rxjs'; +import { AppEvents } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; import { appEvents } from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { saveDashboard } from 'app/features/manage-dashboards/state/actions'; import { DashboardMeta } from 'app/types'; @@ -92,23 +94,27 @@ export class DashboardSrv { starDashboard(dashboardId: string, isStarred: boolean) { const backendSrv = getBackendSrv(); - let promise; - if (isStarred) { - promise = backendSrv.delete('/api/user/stars/dashboard/' + dashboardId).then(() => { - return false; - }); - } else { - promise = backendSrv.post('/api/user/stars/dashboard/' + dashboardId).then(() => { - return true; - }); - } + const request = { + showSuccessAlert: false, + url: '/api/user/stars/dashboard/' + dashboardId, + method: isStarred ? 'DELETE' : 'POST', + }; - return promise.then((res: boolean) => { - if (this.dashboard && this.dashboard.id === dashboardId) { - this.dashboard.meta.isStarred = res; + return backendSrv.request(request).then(() => { + const newIsStarred = !isStarred; + + if (this.dashboard?.id === dashboardId) { + this.dashboard.meta.isStarred = newIsStarred; } - return res; + + const message = newIsStarred + ? t('notifications.starred-dashboard', 'Dashboard starred') + : t('notifications.unstarred-dashboard', 'Dashboard unstarred'); + + appEvents.emit(AppEvents.alertSuccess, [message]); + + return newIsStarred; }); } } diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index 944bf97bd60..93f16735f99 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -1258,15 +1258,15 @@ describe('DashboardModel', () => { ], }); expect(model.panels[0].options).toMatchInlineSnapshot(` - Object { - "tooltip": Object { + { + "tooltip": { "mode": "multi", }, } `); expect(model.panels[1].options).toMatchInlineSnapshot(` - Object { - "tooltip": Object { + { + "tooltip": { "mode": "single", }, } @@ -1314,18 +1314,18 @@ describe('DashboardModel', () => { ], }); expect(model.panels[0].fieldConfig.defaults.mappings).toMatchInlineSnapshot(` - Array [ - Object { - "options": Object { - "20": Object { + [ + { + "options": { + "20": { "color": undefined, "text": "test", }, - "30": Object { + "30": { "color": undefined, "text": "test1", }, - "40": Object { + "40": { "color": "orange", "text": "50", }, @@ -1375,11 +1375,11 @@ describe('DashboardModel', () => { ], }); expect(model.panels[0].fieldConfig.defaults.mappings).toMatchInlineSnapshot(` - Array [ - Object { - "options": Object { + [ + { + "options": { "from": 20, - "result": Object { + "result": { "color": undefined, "text": "text1", }, @@ -1387,10 +1387,10 @@ describe('DashboardModel', () => { }, "type": "range", }, - Object { - "options": Object { + { + "options": { "from": 1, - "result": Object { + "result": { "color": undefined, "text": "text2", }, @@ -1398,10 +1398,10 @@ describe('DashboardModel', () => { }, "type": "range", }, - Object { - "options": Object { + { + "options": { "from": 5, - "result": Object { + "result": { "color": "orange", "text": "50", }, @@ -1471,13 +1471,13 @@ describe('DashboardModel', () => { it('should create two transormatoins', () => { const xforms = model.panels[0].transformations; expect(xforms).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "id": "labelsToFields", }, - Object { + { "id": "merge", - "options": Object {}, + "options": {}, }, ] `); @@ -1909,14 +1909,14 @@ describe('DashboardModel', () => { }); expect(model.panels[0].fieldConfig.overrides).toMatchInlineSnapshot(` - Array [ - Object { - "matcher": Object { + [ + { + "matcher": { "id": "byType", "options": "time", }, - "properties": Array [ - Object { + "properties": [ + { "id": "custom.axisPlacement", "value": "auto", }, diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index b82e710c114..9d154c691bf 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -13,7 +13,7 @@ import { PanelModel } from '../state/PanelModel'; jest.mock('app/core/services/context_srv'); -const mockContextSrv = jest.mocked(contextSrv, true); +const mockContextSrv = jest.mocked(contextSrv); variableAdapters.setInit(() => [ createQueryVariableAdapter(), diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 59294a57330..ebf120deb6b 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -237,11 +237,17 @@ export class PanelModel implements DataConfigSource, IPanelModel { switch (this.type) { case 'graph': - if (config?.featureToggles?.autoMigrateGraphPanels) { + if (config.featureToggles?.autoMigrateGraphPanels || !config.angularSupportEnabled) { this.autoMigrateFrom = this.type; this.type = 'timeseries'; } break; + case 'table-old': + if (!config.angularSupportEnabled) { + this.autoMigrateFrom = this.type; + this.type = 'table'; + } + break; case 'heatmap-new': this.autoMigrateFrom = this.type; this.type = 'heatmap'; @@ -416,7 +422,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { const version = getPluginVersion(plugin); if (this.autoMigrateFrom) { - const wasAngular = this.autoMigrateFrom === 'graph'; + const wasAngular = this.autoMigrateFrom === 'graph' || this.autoMigrateFrom === 'table-old'; this.callPanelTypeChangeHandler( plugin, this.autoMigrateFrom, diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts index a56c2669238..07aa648d7ed 100644 --- a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts @@ -60,12 +60,12 @@ describe('getPanelOptionsWithDefaults', () => { }); expect(result).toMatchInlineSnapshot(` - Object { - "fieldConfig": Object { - "defaults": Object {}, - "overrides": Array [], + { + "fieldConfig": { + "defaults": {}, + "overrides": [], }, - "options": Object {}, + "options": {}, } `); }); @@ -84,29 +84,29 @@ describe('getPanelOptionsWithDefaults', () => { }); expect(result).toMatchInlineSnapshot(` - Object { - "fieldConfig": Object { - "defaults": Object { - "custom": Object { + { + "fieldConfig": { + "defaults": { + "custom": { "hideLines": false, }, - "thresholds": Object { + "thresholds": { "mode": "absolute", - "steps": Array [ - Object { + "steps": [ + { "color": "green", "value": -Infinity, }, - Object { + { "color": "red", "value": 80, }, ], }, }, - "overrides": Array [], + "overrides": [], }, - "options": Object { + "options": { "name": "hello", "number": 10, "showThresholds": true, @@ -135,21 +135,21 @@ describe('getPanelOptionsWithDefaults', () => { }); expect(result).toMatchInlineSnapshot(` - Object { - "fieldConfig": Object { - "defaults": Object { - "custom": Object { + { + "fieldConfig": { + "defaults": { + "custom": { "hideLines": false, }, "decimals": 2, - "thresholds": Object { + "thresholds": { "mode": "absolute", - "steps": Array [ - Object { + "steps": [ + { "color": "green", "value": -Infinity, }, - Object { + { "color": "red", "value": 80, }, @@ -157,9 +157,9 @@ describe('getPanelOptionsWithDefaults', () => { }, "unit": "bytes", }, - "overrides": Array [], + "overrides": [], }, - "options": Object { + "options": { "name": "hello", "number": 20, "showThresholds": false, @@ -292,21 +292,21 @@ describe('getPanelOptionsWithDefaults', () => { }); expect(result.fieldConfig.defaults).toMatchInlineSnapshot(` - Object { - "custom": Object { + { + "custom": { "customProp": 20, - "nested": Object { + "nested": { "nestedA": "A", }, }, - "thresholds": Object { + "thresholds": { "mode": "absolute", - "steps": Array [ - Object { + "steps": [ + { "color": "green", "value": -Infinity, }, - Object { + { "color": "red", "value": 80, }, diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index ab6751711f7..0fae58b3e14 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -20,37 +20,37 @@ describe('getPanelMenu', () => { const menuItems = getPanelMenu(dashboard, panel); expect(menuItems).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "iconClassName": "eye", "onClick": [Function], "shortcut": "v", "text": "View", }, - Object { + { "iconClassName": "edit", "onClick": [Function], "shortcut": "e", "text": "Edit", }, - Object { + { "iconClassName": "share-alt", "onClick": [Function], "shortcut": "p s", "text": "Share", }, - Object { + { "iconClassName": "compass", "onClick": [Function], "shortcut": "x", "text": "Explore", }, - Object { + { "iconClassName": "info-circle", "onClick": [Function], "shortcut": "i", - "subMenu": Array [ - Object { + "subMenu": [ + { "onClick": [Function], "text": "Panel JSON", }, @@ -58,20 +58,20 @@ describe('getPanelMenu', () => { "text": "Inspect", "type": "submenu", }, - Object { + { "iconClassName": "cube", "onClick": [Function], - "subMenu": Array [ - Object { + "subMenu": [ + { "onClick": [Function], "shortcut": "p d", "text": "Duplicate", }, - Object { + { "onClick": [Function], "text": "Copy", }, - Object { + { "onClick": [Function], "text": "Create library panel", }, @@ -79,11 +79,11 @@ describe('getPanelMenu', () => { "text": "More...", "type": "submenu", }, - Object { + { "text": "", "type": "divider", }, - Object { + { "iconClassName": "trash-alt", "onClick": [Function], "shortcut": "p r", @@ -104,37 +104,37 @@ describe('getPanelMenu', () => { const menuItems = getPanelMenu(dashboard, panel, angularComponent); expect(menuItems).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "iconClassName": "eye", "onClick": [Function], "shortcut": "v", "text": "View", }, - Object { + { "iconClassName": "edit", "onClick": [Function], "shortcut": "e", "text": "Edit", }, - Object { + { "iconClassName": "share-alt", "onClick": [Function], "shortcut": "p s", "text": "Share", }, - Object { + { "iconClassName": "compass", "onClick": [Function], "shortcut": "x", "text": "Explore", }, - Object { + { "iconClassName": "info-circle", "onClick": [Function], "shortcut": "i", - "subMenu": Array [ - Object { + "subMenu": [ + { "onClick": [Function], "text": "Panel JSON", }, @@ -142,11 +142,11 @@ describe('getPanelMenu', () => { "text": "Inspect", "type": "submenu", }, - Object { + { "iconClassName": "cube", "onClick": [Function], - "subMenu": Array [ - Object { + "subMenu": [ + { "href": undefined, "onClick": [Function], "shortcut": "p l", diff --git a/public/app/features/dashboard/utils/panelMerge.test.ts b/public/app/features/dashboard/utils/panelMerge.test.ts index 9882379d3a2..cc3f77a1d1d 100644 --- a/public/app/features/dashboard/utils/panelMerge.test.ts +++ b/public/app/features/dashboard/utils/panelMerge.test.ts @@ -49,16 +49,16 @@ describe('Merge dashbaord panels', () => { const info = dashboard.updatePanels(rawPanels); expect(info.changed).toBeFalsy(); expect(info.actions).toMatchInlineSnapshot(` - Object { - "add": Array [], - "noop": Array [ + { + "add": [], + "noop": [ 1, 2, 3, ], - "remove": Array [], - "replace": Array [], - "update": Array [], + "remove": [], + "replace": [], + "update": [], } `); }); @@ -115,17 +115,17 @@ describe('Merge dashbaord panels', () => { const info = dashboard.updatePanels(rawPanels); expect(info.changed).toBeTruthy(); expect(info.actions).toMatchInlineSnapshot(` - Object { - "add": Array [], - "noop": Array [ + { + "add": [], + "noop": [ 1, 3, ], - "remove": Array [], - "replace": Array [ + "remove": [], + "replace": [ 2, ], - "update": Array [], + "update": [], } `); }); diff --git a/public/app/features/dimensions/resource.test.ts b/public/app/features/dimensions/resource.test.ts new file mode 100644 index 00000000000..489e6339abd --- /dev/null +++ b/public/app/features/dimensions/resource.test.ts @@ -0,0 +1,19 @@ +import { getResourceDimension } from './resource'; +import { ResourceDimensionMode } from './types'; + +describe('getResourceDimension', () => { + const publicPath = '/public/'; + beforeAll(() => { + window.__grafana_public_path__ = publicPath; + }); + + it('fixed mode', () => { + const frame = undefined; + const fixedValue = 'img/icons/unicons/question-circle.svg'; + const config = { mode: ResourceDimensionMode.Fixed, fixed: fixedValue }; + + expect(getResourceDimension(frame, config).fixed).toEqual(publicPath + fixedValue); + }); + + // TODO: write tests for field and mapping modes +}); diff --git a/public/app/features/dimensions/resource.ts b/public/app/features/dimensions/resource.ts index 523a044f666..57fed7cb6a9 100644 --- a/public/app/features/dimensions/resource.ts +++ b/public/app/features/dimensions/resource.ts @@ -50,8 +50,12 @@ export function getResourceDimension( // mode === ResourceDimensionMode.Field case const getIcon = (value: string): string => { - const display = field.display!; - return getPublicOrAbsoluteUrl(display(value).icon ?? value ?? ''); + if (field && field.display) { + const icon = field.display(value).icon; + return getPublicOrAbsoluteUrl(icon ?? ''); + } + + return ''; }; return { diff --git a/public/app/features/dimensions/scale.test.ts b/public/app/features/dimensions/scale.test.ts index d10d65e5875..86cb69421f7 100644 --- a/public/app/features/dimensions/scale.test.ts +++ b/public/app/features/dimensions/scale.test.ts @@ -9,7 +9,7 @@ describe('scale dimensions', () => { max: 10, }); expect(out).toMatchInlineSnapshot(` - Object { + { "fixed": 7.5, "max": 10, "min": 5, @@ -30,7 +30,7 @@ describe('scale dimensions', () => { } ); expect(out).toMatchInlineSnapshot(` - Object { + { "fixed": 10, "max": 7, "min": 5, diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 619ef022261..db4aec6269a 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -84,6 +84,7 @@ const dummyProps: Props = { showFlameGraph: true, splitOpen: (() => {}) as any, splitted: false, + isFromCompactUrl: false, eventBus: new EventBusSrv(), }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index e4b3f488fac..128266630d5 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -18,9 +18,10 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; -import { CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2, PanelContainer } from '@grafana/ui'; +import { CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2, PanelContainer, Alert } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types'; import appEvents from 'app/core/app_events'; +import { FadeIn } from 'app/core/components/Animations/FadeIn'; import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils'; @@ -270,6 +271,17 @@ export class Explore extends React.PureComponent { return ; } + renderCompactUrlWarning() { + return ( + + + The URL that brought you here was a compact URL - this format will soon be deprecated. Please replace the URL + previously saved with the URL available now. + + + ); + } + renderGraphPanel(width: number) { const { graphResult, absoluteRange, timeZone, queryResponse, loading, showFlameGraph } = this.props; @@ -382,6 +394,7 @@ export class Explore extends React.PureComponent { showFlameGraph, splitted, timeZone, + isFromCompactUrl, } = this.props; const { openDrawer } = this.state; const styles = getStyles(theme); @@ -407,6 +420,7 @@ export class Explore extends React.PureComponent { scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)} > + {isFromCompactUrl ? this.renderCompactUrlWarning() : null} {datasourceMissing ? this.renderEmptyState(styles.exploreContainer) : null} {datasourceInstance && (
    { } async componentDidMount() { - const { initialized, exploreId, initialDatasource, initialQueries, initialRange, panelsState, orgId } = this.props; + const { + initialized, + exploreId, + initialDatasource, + initialQueries, + initialRange, + panelsState, + orgId, + isFromCompactUrl, + } = this.props; const width = this.el?.offsetWidth ?? 0; // initialize the whole explore first time we mount and if browser history contains a change in datasource if (!initialized) { @@ -108,6 +118,10 @@ class ExplorePaneContainerUnconnected extends React.PureComponent { } } + if (isFromCompactUrl) { + reportInteraction('grafana_explore_compact_notice'); + } + this.props.initializeExplore( exploreId, rootDatasourceOverride || queries[0]?.datasource || initialDatasource, @@ -115,7 +129,8 @@ class ExplorePaneContainerUnconnected extends React.PureComponent { initialRange, width, this.exploreEvents, - panelsState + panelsState, + isFromCompactUrl ); } } @@ -172,6 +187,7 @@ function mapStateToProps(state: StoreState, props: OwnProps) { initialRange, panelsState, orgId: state.user.orgId, + isFromCompactUrl: urlState.isFromCompactUrl || false, }; } diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index f8a0e02f0d4..ce4ec43e2c3 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -22,6 +22,7 @@ import { import { PanelRenderer } from '@grafana/runtime'; import { GraphDrawStyle, LegendDisplayMode, TooltipDisplayMode, SortOrder } from '@grafana/schema'; import { + Button, Icon, PanelContext, PanelContextProvider, @@ -166,13 +167,15 @@ export function ExploreGraph({ {dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
    - {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `} - { - setShowAllTimeSeries(true); - }} - >{`Show all ${dataWithConfig.length}`} + Showing only {MAX_NUMBER_OF_TIME_SERIES} time series. +
    )} ({ timeSeriesDisclaimer: css` label: time-series-disclaimer; - width: 300px; margin: ${theme.spacing(1)} auto; padding: 10px 0; border-radius: ${theme.spacing(2)}; @@ -204,9 +206,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: ${theme.colors.warning.main}; margin-right: ${theme.spacing(0.5)}; `, - showAllTimeSeries: css` - label: show-all-time-series; - cursor: pointer; - color: ${theme.colors.text.link}; + showAllButton: css` + margin-left: ${theme.spacing(0.5)}; `, }); diff --git a/public/app/features/explore/LogsNavigationPages.tsx b/public/app/features/explore/LogsNavigationPages.tsx index b4ab4153d92..2e3a35570db 100644 --- a/public/app/features/explore/LogsNavigationPages.tsx +++ b/public/app/features/explore/LogsNavigationPages.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { dateTimeFormat, systemDateFormats, TimeZone, AbsoluteTimeRange, GrafanaTheme2 } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { CustomScrollbar, Spinner, useTheme2 } from '@grafana/ui'; +import { CustomScrollbar, Spinner, useTheme2, clearButtonStyles } from '@grafana/ui'; import { LogsPage } from './LogsNavigation'; @@ -48,7 +48,8 @@ export function LogsNavigationPages({
    {pages.map((page: LogsPage, index: number) => ( -
    {createPageContent(page, index)}
    -
    + ))}
    @@ -101,6 +102,7 @@ const getStyles = (theme: GrafanaTheme2, loading: boolean) => { flex-direction: column; `, page: css` + ${clearButtonStyles(theme)} display: flex; margin: ${theme.spacing(2)} 0; cursor: ${loading ? 'auto' : 'pointer'}; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index d67432a6226..882057b2928 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -260,6 +260,7 @@ export function RichHistoryCard(props: Props) { const updateComment = (