diff --git a/.betterer.results b/.betterer.results index fcb25c5f876..43127440a4e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -523,7 +523,8 @@ exports[`better eslint`] = { ], "packages/grafana-data/src/types/logs.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.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] ], "packages/grafana-data/src/types/logsVolume.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -697,13 +698,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], - "packages/grafana-data/src/utils/dataLinks.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "packages/grafana-data/src/utils/dataLinks.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/utils/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -1103,8 +1099,7 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/ColorPicker/ColorPicker.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, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "2"] ], "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] @@ -1622,8 +1617,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-ui/src/slate-plugins/braces.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"] ], "packages/grafana-ui/src/slate-plugins/slate-prism/index.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -1695,101 +1689,6 @@ exports[`better eslint`] = { "packages/grafana-ui/src/utils/useAsyncDependency.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "packages/jaeger-ui-components/src/ScrollManager.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"] - ], - "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.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"] - ], - "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/common/BreakableText.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/common/UiFindInput.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "packages/jaeger-ui-components/src/constants/index.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"] - ], - "packages/jaeger-ui-components/src/constants/tag-keys.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"] - ], - "packages/jaeger-ui-components/src/keyboard-shortcuts.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], - "packages/jaeger-ui-components/src/model/link-patterns.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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, "Do not use any type assertions.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"] - ], - "packages/jaeger-ui-components/src/model/transform-trace-data.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"] - ], - "packages/jaeger-ui-components/src/types/api-error.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/types/links.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/types/trace.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/jaeger-ui-components/src/utils/date.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "plugins-bundled/internal/input-datasource/src/InputDatasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -2476,6 +2375,24 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], + "public/app/core/components/TimelineChart/TimelineChart.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/core/components/TimelineChart/timeline.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, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"] + ], + "public/app/core/components/TimelineChart/utils.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/core/components/TimelineChart/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/core/components/connectWithCleanUp.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -2983,8 +2900,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/alerting/unified/components/rules/RulesFilter.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.", "0"] ], "public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -3597,15 +3513,14 @@ exports[`better eslint`] = { [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, "Do not use any type assertions.", "29"], + [0, 0, 0, "Do not use any type assertions.", "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.", "35"] ], "public/app/features/dashboard/state/PanelModel.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3877,16 +3792,66 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Do not use any type assertions.", "5"] ], - "public/app/features/explore/TraceView/createSpanLink.tsx:5381": [ + "public/app/features/explore/TraceView/components/ScrollManager.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.", "5"], - [0, 0, 0, "Do not use any type assertions.", "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.", "2"] + ], + "public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.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"] + ], + "public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/explore/TraceView/components/TraceTimelineViewer/utils.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/explore/TraceView/components/common/BreakableText.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/explore/TraceView/components/constants/index.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/explore/TraceView/components/demo/trace-generators.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/explore/TraceView/components/model/link-patterns.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/features/explore/TraceView/components/model/link-patterns.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "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, "Do not use any type assertions.", "10"], + [0, 0, 0, "Unexpected any. Specify a different type.", "11"], + [0, 0, 0, "Do not use any type assertions.", "12"], + [0, 0, 0, "Unexpected any. Specify a different type.", "13"] + ], + "public/app/features/explore/TraceView/components/model/transform-trace-data.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/explore/TraceView/components/types/trace.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/explore/TraceView/createSpanLink.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/explore/spec/helper/setup.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -4587,8 +4552,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/search/utils.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/features/serviceaccounts/ServiceAccountPage.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -6100,9 +6064,10 @@ exports[`better eslint`] = { [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.", "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.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"] ], "public/app/plugins/datasource/loki/getDerivedFields.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -6695,12 +6660,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "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, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/plugins/datasource/testdata/components/PredictablePulseEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -6735,10 +6697,7 @@ exports[`better eslint`] = { "public/app/plugins/datasource/testdata/datasource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/testdata/module.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -6758,9 +6717,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/datasource/testdata/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/zipkin/QueryField.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -7510,10 +7466,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/panel/state-timeline/TimelineChart.tsx: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/state-timeline/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -7524,22 +7476,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "6"], [0, 0, 0, "Do not use any type assertions.", "7"] ], - "public/app/plugins/panel/state-timeline/timeline.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, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"] - ], - "public/app/plugins/panel/state-timeline/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/plugins/panel/state-timeline/utils.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/plugins/panel/state-timeline/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/table-old/column_options.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/.dockerignore b/.dockerignore index 7ed8d01615e..1df915d2461 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,6 @@ .dockerignore .git .gitignore -.github .vscode bin data* diff --git a/.drone.yml b/.drone.yml index bbc16269349..e787f4007f8 100644 --- a/.drone.yml +++ b/.drone.yml @@ -84,13 +84,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -98,7 +98,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: event: @@ -141,7 +141,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn run prettier:check @@ -152,7 +152,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: lint-frontend trigger: event: @@ -206,7 +206,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -214,25 +214,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: event: @@ -286,7 +286,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update && apt-get install make @@ -359,7 +359,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -367,18 +367,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" @@ -403,7 +403,7 @@ steps: from_secret: github_token_pr TEST_TAG: v0.0.0-test failure: ignore - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: trigger-test-release when: paths: @@ -430,7 +430,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -439,7 +439,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -448,7 +448,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -456,7 +456,7 @@ steps: - compile-build-cmd - yarn-install environment: null - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - . scripts/build/gpg-test-vars.sh && ./bin/build package --jobs 8 --edition oss @@ -467,7 +467,7 @@ steps: - build-frontend - build-frontend-packages environment: null - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ./scripts/grafana-server/start-server @@ -480,7 +480,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -583,7 +583,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-storybook when: paths: @@ -594,7 +594,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -696,7 +696,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -704,13 +704,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -726,7 +726,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -742,7 +742,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests trigger: event: @@ -800,7 +800,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - |- @@ -812,7 +812,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: codespell - commands: - yarn run prettier:checkDocs @@ -820,7 +820,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -870,7 +870,7 @@ steps: - ./bin/build shellcheck depends_on: - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: shellcheck trigger: event: @@ -917,7 +917,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - |- @@ -929,7 +929,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: codespell - commands: - yarn run prettier:checkDocs @@ -937,7 +937,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -990,13 +990,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -1004,7 +1004,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: branch: main @@ -1044,7 +1044,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn run prettier:check @@ -1055,7 +1055,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: lint-frontend trigger: branch: main @@ -1106,7 +1106,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1114,25 +1114,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: branch: main @@ -1179,7 +1179,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update && apt-get install make @@ -1251,7 +1251,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1259,25 +1259,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - ./bin/build build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1286,7 +1286,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1295,7 +1295,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1305,7 +1305,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -1323,7 +1323,7 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ./scripts/grafana-server/start-server @@ -1336,7 +1336,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -1439,7 +1439,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-storybook when: paths: @@ -1450,7 +1450,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -1494,7 +1494,7 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: publish-frontend-metrics when: repo: @@ -1575,7 +1575,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: release-canary-npm-packages when: repo: @@ -1686,7 +1686,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1694,13 +1694,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -1716,7 +1716,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -1732,7 +1732,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests trigger: branch: main @@ -1970,18 +1970,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -1995,7 +1995,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss ${DRONE_TAG} @@ -2004,7 +2004,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG} @@ -2013,7 +2013,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -2023,7 +2023,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --sign ${DRONE_TAG} @@ -2041,14 +2041,14 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -2087,7 +2087,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -2164,7 +2164,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-storybook when: event: @@ -2223,7 +2223,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: store-npm-packages trigger: event: @@ -2272,13 +2272,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2286,7 +2286,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: event: @@ -2334,7 +2334,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2342,25 +2342,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: event: @@ -2427,7 +2427,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2435,13 +2435,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -2457,7 +2457,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -2473,7 +2473,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests trigger: event: @@ -2590,7 +2590,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2606,7 +2606,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -2620,13 +2620,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2636,7 +2636,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2645,14 +2645,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2661,7 +2661,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2670,7 +2670,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -2680,7 +2680,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition enterprise --sign ${DRONE_TAG} @@ -2698,14 +2698,14 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -2745,7 +2745,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -2877,7 +2877,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2893,7 +2893,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -2909,14 +2909,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2925,7 +2925,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: event: @@ -2962,7 +2962,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mkdir -p bin @@ -2984,7 +2984,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -3006,7 +3006,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3015,25 +3015,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: event: @@ -3106,7 +3106,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3122,7 +3122,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3132,7 +3132,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3141,13 +3141,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -3163,7 +3163,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -3179,7 +3179,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -3190,7 +3190,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -3201,7 +3201,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: memcached-integration-tests trigger: event: @@ -3338,7 +3338,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3354,7 +3354,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -3368,13 +3368,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3384,7 +3384,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -3393,7 +3393,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -3402,7 +3402,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -3412,14 +3412,14 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 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.6 + image: grafana/build-container:1.6.7 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} @@ -3437,7 +3437,7 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package-enterprise2 - commands: - ./bin/build upload-cdn --edition enterprise2 @@ -3457,7 +3457,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package-enterprise2 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise2 --shouldSave @@ -3588,7 +3588,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3604,7 +3604,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -3618,13 +3618,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3634,7 +3634,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -3643,7 +3643,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -3652,7 +3652,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -3662,14 +3662,14 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 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.6 + image: grafana/build-container:1.6.7 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} @@ -3687,7 +3687,7 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package-enterprise2 - commands: - ./bin/build upload-cdn --edition enterprise2 @@ -3707,7 +3707,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package-enterprise2 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise2 --shouldSave @@ -4366,7 +4366,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - ./bin/build artifacts npm retrieve --tag ${DRONE_TAG} @@ -4390,7 +4390,7 @@ steps: NPM_TOKEN: from_secret: npm_token failure: ignore - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: release-npm-packages trigger: event: @@ -4626,7 +4626,7 @@ steps: environment: GCP_KEY: from_secret: gcp_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: artifacts-page trigger: event: @@ -4671,18 +4671,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -4696,7 +4696,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -4705,7 +4705,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -4714,7 +4714,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -4724,7 +4724,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -4742,14 +4742,14 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -4788,7 +4788,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -4865,7 +4865,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-storybook when: paths: @@ -4946,13 +4946,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4960,7 +4960,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: ref: @@ -5005,7 +5005,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5013,25 +5013,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: ref: @@ -5095,7 +5095,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5103,13 +5103,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -5125,7 +5125,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -5141,7 +5141,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests trigger: ref: @@ -5248,7 +5248,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5263,7 +5263,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -5277,13 +5277,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -5293,7 +5293,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5302,14 +5302,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -5318,7 +5318,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -5327,7 +5327,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -5337,7 +5337,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -5356,14 +5356,14 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -5403,7 +5403,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: grafana-server - commands: - apt-get install -y netcat @@ -5538,7 +5538,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5553,7 +5553,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -5569,14 +5569,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -5585,7 +5585,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-frontend trigger: ref: @@ -5619,7 +5619,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mkdir -p bin @@ -5640,7 +5640,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -5662,7 +5662,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5671,25 +5671,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: test-backend-integration trigger: ref: @@ -5759,7 +5759,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5774,7 +5774,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -5784,7 +5784,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5793,13 +5793,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - apt-get update @@ -5815,7 +5815,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: postgres-integration-tests - commands: - apt-get update @@ -5831,7 +5831,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -5842,7 +5842,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -5853,7 +5853,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: memcached-integration-tests trigger: ref: @@ -5980,7 +5980,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5995,7 +5995,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -6009,13 +6009,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -6025,7 +6025,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: verify-gen-cue - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -6034,7 +6034,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -6043,7 +6043,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -6053,7 +6053,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -6061,7 +6061,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -6080,7 +6080,7 @@ steps: from_secret: packages_gpg_public_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: package-enterprise2 - commands: - ./bin/build upload-cdn --edition enterprise2 @@ -6100,7 +6100,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package-enterprise2 - image: grafana/build-container:1.6.6 + image: grafana/build-container:1.6.7 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise2 --shouldSave @@ -6451,6 +6451,6 @@ kind: secret name: aws_secret_access_key --- kind: signature -hmac: 5b2831b92627887ebe7ee9963796449981a12a96ea775a331fb84536598536ce +hmac: 86222a86386ae1d2afce23b4a15f09e97aaeb873275ed34ac73d733335d63e4b ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4544d4f52c9..84dec49745d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,26 +12,26 @@ # This should make it easy to add new rules without breaking existing ones. # Documentation -/docs/ @grafana/docs-squad -/contribute/ @grafana/docs-squad -/docs/sources/developers/plugins/ @grafana/docs-squad @grafana/plugins-platform-frontend @grafana/plugins-platform-backend -/docs/sources/developers/plugins/backend/ @grafana/docs-squad @grafana/plugins-platform-backend -/.changelog-archive @grafana/docs-squad -CHANGELOG.md @grafana/docs-squad -CODE_OF_CONDUCT.md @grafana/docs-squad -CONTRIBUTING.md @grafana/docs-squad +/docs/ @grafana/docs-grafana +/contribute/ @grafana/docs-grafana +/docs/sources/developers/plugins/ @grafana/docs-grafana @grafana/plugins-platform-frontend @grafana/plugins-platform-backend +/docs/sources/developers/plugins/backend/ @grafana/docs-grafana @grafana/plugins-platform-backend +/.changelog-archive @grafana/docs-grafana +CHANGELOG.md @grafana/docs-grafana +CODE_OF_CONDUCT.md @grafana/docs-grafana +CONTRIBUTING.md @grafana/docs-grafana GOVERNANCE.md @RichiH -HALL_OF_FAME.md @grafana/docs-squad +HALL_OF_FAME.md @grafana/docs-grafana ISSUE_TRIAGE.md @grafana/grafana-community-support LICENSE @torkelo LICENSING.md @torkelo MAINTAINERS.md @RichiH NOTICE.md @torkelo -README.md @grafana/docs-squad +README.md @grafana/docs-grafana ROADMAP.md @torkelo SECURITY.md @grafana/security-team SUPPORT.md @torkelo -UPGRADING_DEPENDENCIES.md @grafana/docs-squad +UPGRADING_DEPENDENCIES.md @grafana/docs-grafana WORKFLOW.md @torkelo @@ -112,7 +112,7 @@ WORKFLOW.md @torkelo /pkg/services/pluginsettings/ @grafana/backend-platform /pkg/services/preference/ @grafana/backend-platform /pkg/services/provisioning/ @grafana/backend-platform -/pkg/services/publicdashboards/ @grafana/backend-platform +/pkg/services/publicdashboards/ @grafana/dashboards-squad /pkg/services/query/ @grafana/backend-platform /pkg/services/queryhistory/ @grafana/backend-platform /pkg/services/quota/ @grafana/backend-platform @@ -259,7 +259,6 @@ WORKFLOW.md @torkelo /pkg/services/querylibrary/ @grafana/multitenancy-squad /pkg/services/export/ @grafana/multitenancy-squad /pkg/infra/filestorage/ @grafana/multitenancy-squad -/pkg/tsdb/testdatasource/ @grafana/multitenancy-squad /pkg/util/converter/ @grafana/multitenancy-squad # Alerting @@ -281,6 +280,7 @@ WORKFLOW.md @torkelo /pkg/services/datasources/ @grafana/plugins-platform-backend /pkg/services/pluginsintegration/ @grafana/plugins-platform-backend /pkg/plugins/pfs/ @grafana/plugins-platform-backend @grafana/grafana-as-code +/pkg/tsdb/testdatasource/ @grafana/plugins-platform-backend # Dashboard previews / crawler (behind feature flag) /pkg/services/thumbs/ @grafana/multitenancy-squad @@ -306,7 +306,7 @@ WORKFLOW.md @torkelo /packages/grafana-ui/src/components/TimeSeries/ @grafana/grafana-bi-squad /packages/grafana-ui/src/components/uPlot/ @grafana/grafana-bi-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend -/packages/jaeger-ui-components// @grafana/observability-traces-and-profiling +/packages/grafana-data/src/**/*logs* @grafana/observability-logs /plugins-bundled/ @grafana/plugins-platform-frontend @@ -432,6 +432,10 @@ lerna.json @grafana/frontend-ops /public/testdata/ @grafana/user-essentials /public/views/ @grafana/user-essentials +/public/app/features/explore/Logs.tsx @grafana/observability-logs +/public/app/features/explore/LogsContainer.tsx @grafana/observability-logs + +/public/app/features/explore/TraceView/ @grafana/observability-traces-and-profiling /public/api-merged.json @grafana/backend-platform /public/api-spec.json @grafana/backend-platform @@ -486,7 +490,7 @@ lerna.json @grafana/frontend-ops /public/app/plugins/datasource/cloudwatch/ @grafana/aws-plugins /public/app/plugins/datasource/elasticsearch/ @grafana/observability-logs /public/app/plugins/datasource/grafana/ @grafana/user-essentials -/public/app/plugins/datasource/testdata/ @grafana/backend-platform +/public/app/plugins/datasource/testdata/ @grafana/plugins-platform-frontend /public/app/plugins/datasource/grafana-azure-monitor-datasource/ @grafana/partner-plugins /public/app/plugins/datasource/graphite/ @grafana/observability-metrics /public/app/plugins/datasource/influxdb/ @grafana/observability-metrics @@ -535,9 +539,12 @@ lerna.json @grafana/frontend-ops /pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-partnerships-team +# Kind definitions +/kinds/dashboard @grafana/dashboards-squad +/kinds/ @grafana/grafana-as-code + # Kind system and code generation embed.go @grafana/grafana-as-code -/kinds/ @grafana/grafana-as-code /pkg/kinds/ @grafana/grafana-as-code /pkg/cuectx/ @grafana/grafana-as-code /pkg/registry/ @grafana/grafana-as-code @@ -564,12 +571,12 @@ embed.go @grafana/grafana-as-code /.github/workflows/backport.yml @grafana/grafana-release-eng /.github/workflows/bump-version.yml @grafana/grafana-release-eng /.github/workflows/close-milestone.yml @grafana/grafana-release-eng -/.github/workflows/cloud-data-sources-code-coverage.yml @grafana/partner-plugins +/.github/workflows/cloud-data-sources-code-coverage.yml @grafana/partner-plugins @grafana/aws-plugins /.github/workflows/codeowners-validator.yml @tolzhabayev /.github/workflows/codeql-analysis.yml @DanCech /.github/workflows/commands.yml @torkelo /.github/workflows/detect-breaking-changes-* @grafana/plugins-platform-frontend -/.github/workflows/doc-validator.yml @grafana/docs-squad +/.github/workflows/doc-validator.yml @grafana/docs-grafana /.github/workflows/enterprise-pr-check.yml @grafana/grafana-release-eng /.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina /.github/workflows/github-release.yml @torkelo @@ -583,8 +590,8 @@ embed.go @grafana/grafana-as-code /.github/workflows/pr-codeql-analysis-python.yml @DanCech /.github/workflows/pr-commands-closed.yml @tolzhabayev /.github/workflows/pr-commands.yml @marefr -/.github/workflows/publish-technical-documentation-next.yml @grafana/docs-squad -/.github/workflows/publish-technical-documentation-release.yml @grafana/docs-squad +/.github/workflows/publish-technical-documentation-next.yml @grafana/docs-grafana +/.github/workflows/publish-technical-documentation-release.yml @grafana/docs-grafana /.github/workflows/remove-milestone.yml @grafana/user-essentials /.github/workflows/sbom-report.yml @grafana/security-team /.github/workflows/scripts/json-file-to-job-output.js @grafana/plugins-platform-frontend @@ -604,6 +611,3 @@ embed.go @grafana/grafana-as-code /conf/provisioning/datasources/ @grafana/plugins-platform-backend /conf/provisioning/notifiers/ @bergquist /conf/provisioning/plugins/ @grafana/plugins-platform-backend - - - diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1ed4455944f..a828336afbd 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -5,7 +5,6 @@ "enabledManagers": ["npm"], "ignoreDeps": [ "@types/systemjs", - "@types/react-icons", // jaeger-ui-components is being refactored to use @grafana/ui icons instead "commander", // we are planning to remove this, so no need to update it "execa", // we should bump this once we move to esm modules "history", // we should bump this together with react-router-dom @@ -13,7 +12,6 @@ "monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins "node-fetch", // we should bump this once we move to esm modules "react-hook-form", // due to us exposing these hooks via @grafana/ui form components bumping can break plugins - "react-icons", // jaeger-ui-components is being refactored to use @grafana/ui icons instead "react-redux", // react-beautiful-dnd depends on react-redux 7.x, we need to update that one first "react-router-dom", // we should bump this together with history "systemjs", diff --git a/.github/workflows/cloud-data-sources-code-coverage.yml b/.github/workflows/cloud-data-sources-code-coverage.yml index c95c666d869..bb27cb086de 100644 --- a/.github/workflows/cloud-data-sources-code-coverage.yml +++ b/.github/workflows/cloud-data-sources-code-coverage.yml @@ -14,7 +14,7 @@ on: jobs: workflow-call: - uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.14 + uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.17 with: frontend-path-regexp: public\/app\/plugins\/datasource\/(grafana-azure-monitor-datasource|cloud-monitoring|cloudwatch) backend-path-regexp: pkg\/tsdb\/(azuremonitor|cloudmonitoring|cloudwatch) diff --git a/.github/workflows/doc-validator.yml b/.github/workflows/doc-validator.yml index 695b07ef338..5444eef33a0 100644 --- a/.github/workflows/doc-validator.yml +++ b/.github/workflows/doc-validator.yml @@ -7,10 +7,10 @@ jobs: doc-validator: runs-on: "ubuntu-latest" container: - image: "grafana/doc-validator:v1.5.0" + image: "grafana/doc-validator:v1.9.0" steps: - name: "Checkout code" uses: "actions/checkout@v3" - name: "Run doc-validator tool" # Ensure that the CI always passes until all errors are resolved. - run: "doc-validator ./docs/sources || true" + run: "doc-validator --skip-image-validation ./docs/sources /docs/grafana/latest || true" diff --git a/.github/workflows/ox-code-coverage.yml b/.github/workflows/ox-code-coverage.yml index 460e0070133..7413350f646 100644 --- a/.github/workflows/ox-code-coverage.yml +++ b/.github/workflows/ox-code-coverage.yml @@ -15,7 +15,7 @@ on: jobs: workflow-call: - uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.14 + uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.17 with: frontend-path-regexp: public\/app\/features\/(explore|correlations)|public\/app\/plugins\/datasource\/(loki|elasticsearch) backend-path-regexp: pkg\/services\/(queryhistory)|pkg\/tsdb\/(loki|elasticsearch) diff --git a/.gitignore b/.gitignore index a4c02bfa4ca..7c44ef5bdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,8 @@ compilation-stats.json /e2e/benchmarks/**/results/* /e2e/benchmarks/**/results /e2e/build_results.zip +/e2e/extensions +/e2e/extensions-suite # grafana server /scripts/grafana-server/server.log diff --git a/.nvmrc b/.nvmrc index bf79505bb85..9dfef472196 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.0 +v18.12.0 diff --git a/.prettierignore b/.prettierignore index 62700e93ea4..0f07251b065 100644 --- a/.prettierignore +++ b/.prettierignore @@ -36,3 +36,5 @@ kinds/report.json # Generated schema docs docs/sources/developers/kinds/ + +scripts/cli/bettererIssueTemplate.md diff --git a/.yarn/sdks/eslint/package.json b/.yarn/sdks/eslint/package.json index 379e652a6e4..c0e941b7605 100644 --- a/.yarn/sdks/eslint/package.json +++ b/.yarn/sdks/eslint/package.json @@ -1,6 +1,6 @@ { "name": "eslint", - "version": "8.30.0-sdk", + "version": "8.32.0-sdk", "main": "./lib/api.js", "type": "commonjs" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c30477ca0a3..97e79f113de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,42 @@ + + +# 9.3.6 (2023-01-26) + +### Bug fixes + +- **QueryEditorRow:** Fixes issue loading query editor when data source variable selected. [#61927](https://github.com/grafana/grafana/pull/61927), [@torkelo](https://github.com/torkelo) + + + + +# 9.3.4 (2023-01-25) + +### Features and enhancements + +- **Prometheus:** Add default editor configuration. [#61510](https://github.com/grafana/grafana/pull/61510), [@itsmylife](https://github.com/itsmylife) +- **TextPanel:** Refactor to functional component (#60885). [#61937](https://github.com/grafana/grafana/pull/61937), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Alerting:** Fix webhook to use correct key for decrypting token. [#61717](https://github.com/grafana/grafana/pull/61717), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Alerting:** Set error annotation on EvaluationError regardless of underlying error type. [#61506](https://github.com/grafana/grafana/pull/61506), [@alexweav](https://github.com/alexweav) +- **Datasources:** Fix Proxy by UID Failing for UIDs with a Hyphen. [#61723](https://github.com/grafana/grafana/pull/61723), [@csmarchbanks](https://github.com/csmarchbanks) +- **Elasticsearch:** Fix creating of span link with no tags. [#61753](https://github.com/grafana/grafana/pull/61753), [@ivanahuckova](https://github.com/ivanahuckova) +- **Elasticsearch:** Fix failing requests when using SigV4. [#61923](https://github.com/grafana/grafana/pull/61923), [@svennergr](https://github.com/svennergr) +- **Elasticsearch:** Fix toggle-settings are not shown correctly. [#61751](https://github.com/grafana/grafana/pull/61751), [@svennergr](https://github.com/svennergr) +- **Explore:** Be sure time range key bindings are mounted after clear. [#61892](https://github.com/grafana/grafana/pull/61892), [@gelicia](https://github.com/gelicia) +- **Explore:** Unsync time ranges when a pane is closed. [#61369](https://github.com/grafana/grafana/pull/61369), [@Elfo404](https://github.com/Elfo404) +- **Logs:** Lines with long words do not break properly. [#61707](https://github.com/grafana/grafana/pull/61707), [@svennergr](https://github.com/svennergr) +- **Loki:** Fix misaligned derived fields settings. [#61475](https://github.com/grafana/grafana/pull/61475), [@svennergr](https://github.com/svennergr) +- **Query Builder:** Fix max width of input component to prevent overflows. [#61798](https://github.com/grafana/grafana/pull/61798), [@matyax](https://github.com/matyax) +- **Search:** Auto focus input elements. [#61443](https://github.com/grafana/grafana/pull/61443), [@ryantxu](https://github.com/ryantxu) +- **Search:** Fix empty folder message showing when by starred dashboards. [#61610](https://github.com/grafana/grafana/pull/61610), [@eledobleefe](https://github.com/eledobleefe) +- **Table Panel:** Fix image of image cell overflowing table cell and cells ignoring text alignment setting when a data link is added. [#59392](https://github.com/grafana/grafana/pull/59392), [@oscarkilhed](https://github.com/oscarkilhed) + + -# 9.3.2 (unreleased) +# 9.3.2 (2023-12-13) ### Features and enhancements @@ -288,6 +324,23 @@ 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.10 (2023-01-24) + +### Features and enhancements + +- **TextPanel:** Refactor to functional component (#60885). [#61940](https://github.com/grafana/grafana/pull/61940), [@ryantxu](https://github.com/ryantxu) +- **[v9.2.x] Chore:** Upgrade Go to 1.19.4. [#60826](https://github.com/grafana/grafana/pull/60826), [@sakjur](https://github.com/sakjur) + +### Bug fixes + +- **Live:** Fix `Subscription to the channel already exists` live streaming error. [#61420](https://github.com/grafana/grafana/pull/61420), [@grafanabot](https://github.com/grafanabot) +- **Live:** Fix `Subscription to the channel already exists` live streaming error. [#61419](https://github.com/grafana/grafana/pull/61419), [@grafanabot](https://github.com/grafanabot) +- **Live:** Fix `Subscription to the channel already exists` live streaming error. [#61406](https://github.com/grafana/grafana/pull/61406), [@ArturWierzbicki](https://github.com/ArturWierzbicki) + + + # 9.2.7 (2022-11-29) @@ -1953,6 +2006,16 @@ In the Loki data source, for consistency and performance reasons, we changed how The dependency to [grafana/aws-sdk](https://github.com/grafana/grafana-aws-sdk-react) is moved from [grafana/ui](https://github.com/grafana/grafana/blob/main/packages/grafana-ui/package.json) to the plugin. This means that any plugin that use SIGV4 auth need to pass a SIGV4 editor component as a prop to the `DataSourceHttpSettings` component. Issue [#43559](https://github.com/grafana/grafana/issues/43559) + + +# 8.5.20 (2023-01-25) + +### Features and enhancements + +- **Chore:** Upgrade Go to 1.19.4 [v8.5.x]. [#60824](https://github.com/grafana/grafana/pull/60824), [@sakjur](https://github.com/sakjur) + + + # 8.5.15 (2022-11-08) diff --git a/Dockerfile b/Dockerfile index e9bbd124e01..f5d3881f5b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG BASE_IMAGE=alpine:3.15 -ARG JS_IMAGE=node:16-alpine3.15 +ARG JS_IMAGE=node:18-alpine3.15 ARG GO_IMAGE=golang:1.19.4-alpine3.17 ARG GO_SRC=go-builder @@ -53,6 +53,7 @@ COPY public/api-spec.json public/api-spec.json COPY pkg pkg COPY scripts scripts COPY conf conf +COPY .github .github RUN make build-go diff --git a/LICENSING.md b/LICENSING.md index 7b64973e012..696cf188f9b 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -15,7 +15,6 @@ packages/grafana-e2e-selectors/ packages/grafana-runtime/ packages/grafana-toolkit/ packages/grafana-ui/ -packages/jaeger-ui-components/ packaging/ kinds/ pkg/kinds/ @@ -23,6 +22,7 @@ pkg/kindsys/ pkg/registry/corekind/ grafana-mixin/ public/app/plugins/datasource/tempo +public/app/features/explore/TraceView/components public/img/icons/solid/ public/img/icons/unicons/ ``` diff --git a/conf/defaults.ini b/conf/defaults.ini index 2a5884efc9e..d67385d1db5 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -366,6 +366,9 @@ data_keys_cache_cleanup_interval = 1m #################################### Snapshots ########################### [snapshots] +# set to false to remove snapshot functionality +enabled = true + # snapshot sharing options external_enabled = true external_snapshot_url = https://snapshots.raintank.io @@ -546,6 +549,7 @@ allowed_groups = role_attribute_path = role_attribute_strict = false allow_assign_grafana_admin = false +skip_org_role_sync = false #################################### Google Auth ######################### [auth.google] diff --git a/conf/sample.ini b/conf/sample.ini index f509496244d..546f828ac45 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -372,6 +372,9 @@ #################################### Snapshots ########################### [snapshots] +# set to false to remove snapshot functionality +;enabled = true + # snapshot sharing options ;external_enabled = true ;external_snapshot_url = https://snapshots.raintank.io @@ -550,6 +553,7 @@ ;role_attribute_path = ;role_attribute_strict = false ;allow_assign_grafana_admin = false +;skip_org_role_sync = false #################################### Google Auth ########################## [auth.google] diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 202d9e192b2..fb20192b939 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -18,7 +18,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen ``` brew install git brew install go -brew install node@16 +brew install node@18 npm install -g yarn ``` diff --git a/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json new file mode 100644 index 00000000000..be7a3567bfa --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json @@ -0,0 +1,761 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1263, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 11, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "title": "default", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default absolute thresholds", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 8, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default percentage thresholds", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 3, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override thresholds", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 6, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default value mappings", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 7, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override value mappings", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n },\n {\n \"color\": \"#EAB839\",\n \"value\": 10\n },\n {\n \"color\": \"red\",\n \"value\": 15\n },\n {\n \"color\": \"#6ED0E0\",\n \"value\": 25\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000\n ],\n [\n 5,\n 10,\n 20,\n 30\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "field thresholds from data", + "type": "state-timeline" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 9, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "max": 30, + "min": 0.01, + "noise": 30, + "refId": "B", + "scenarioId": "random_walk", + "startValue": 1 + } + ], + "title": "threshold from random walk", + "transformations": [ + { + "id": "configFromData", + "options": { + "configRefId": "B", + "mappings": [ + { + "fieldName": "B-series", + "handlerKey": "threshold1" + } + ] + } + } + ], + "type": "state-timeline" + } + ], + "refresh": false, + "revision": 1, + "schemaVersion": 38, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "state-timeline", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2023-01-26T11:33:55.000Z", + "to": "2023-01-26T14:33:55.000Z" + }, + "timepicker": {}, + "timezone": "", + "title": "StateTimeline - Thresholds & Mappings", + "uid": "Kce7z9TVz", + "version": 14, + "weekStart": "" +} diff --git a/devenv/docker/blocks/auth/oauth/readme.md b/devenv/docker/blocks/auth/oauth/readme.md index 60c87d8d508..a935f2c8682 100644 --- a/devenv/docker/blocks/auth/oauth/readme.md +++ b/devenv/docker/blocks/auth/oauth/readme.md @@ -59,7 +59,7 @@ You can obtain a jwt token by using the following command for oauth-admin: ```sh curl --request POST \ - --url http://localhost:8087/auth/realms/grafana/protocol/openid-connect/token \ + --url http://localhost:8087/realms/grafana/protocol/openid-connect/token \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data client_id=grafana-oauth \ --data grant_type=password \ diff --git a/devenv/docker/blocks/loki/data/data.js b/devenv/docker/blocks/loki/data/data.js index fed60c9e70b..1dcc4ccb053 100644 --- a/devenv/docker/blocks/loki/data/data.js +++ b/devenv/docker/blocks/loki/data/data.js @@ -96,7 +96,7 @@ function escapeLogFmtKey(key) { function escapeLogFmtValue(value) { if (logFmtProblemRe.test(value)) { - throw new Error(`invalid logfmt-value: ${key}`) + throw new Error(`invalid logfmt-value: ${value}`) } // we must handle the space-character because we have values with spaces :-( diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 60a5faa1694..a8ec282659e 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -632,6 +632,13 @@ local dashboard = grafana.dashboard; id: 0, } }, + dashboard.new('timeline-thresholds-mappings', import '../dev-dashboards/panel-timeline/timeline-thresholds-mappings.json') + + resource.addMetadata('folder', 'dev-dashboards') + + { + spec+: { + id: 0, + } + }, dashboard.new('timeseries', import '../dev-dashboards/panel-timeseries/timeseries.json') + resource.addMetadata('folder', 'dev-dashboards') + { diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index c6b07ec078c..c243fc68f12 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -215,6 +215,7 @@ Common settings in the [built-in core data sources]({{< relref "../../datasource | prometheusVersion | string | Prometheus | The version of the Prometheus data source, such as `2.37.0`, `2.24.0` | | prometheusType | string | Prometheus | The type of the Prometheus data sources. such as `Prometheus`, `Cortex`, `Thanos`, `Mimir` | | implementation | string | AlertManager | The implementation of the AlertManager data source, such as `prometheus`, `cortex` or `mimir` | +| handleGrafanaManagedAlerts | boolean | AlertManager | When enabled, Grafana-managed alerts are sent to this Alertmanager | For examples of specific data sources' JSON data, refer to that [data source's documentation]({{< relref "../../datasources" >}}). 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 6bbc25f90f6..4c9d6ee7e46 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 @@ -36,19 +36,17 @@ 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, 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 1, add the rule name. The recording name must be a Prometheus metric name and contain no whitespace. + - In **Rule name**, add a descriptive name. 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 3, add the namespace, rule group, as well as additional metadata associated with the rule. + - Select your Loki or Prometheus data source. + - Enter a PromQL or LogQL query. +1. In Step 3, add the namespace and the group. - 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. 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. + - 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. +1. In Step 4, add the 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 recording rule or **Save and exit** to save the recording rule and go back to the Alerting page. 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. @@ -57,13 +55,12 @@ To create a Grafana Mimir or Loki managed recording 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 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 evaluation behavior. + - Enter a valid **For** duration. The expression has to be true for this long for the alert to be fired. 1. In Step 4, add 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. 1. In Step 5, add custom labels. diff --git a/docs/sources/alerting/fundamentals/alert-rules/message-templating.md b/docs/sources/alerting/fundamentals/alert-rules/message-templating.md index 0d43a2d948c..cd563f1e4c0 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/message-templating.md +++ b/docs/sources/alerting/fundamentals/alert-rules/message-templating.md @@ -18,7 +18,7 @@ weight: 415 Notifications sent via contact points are built using notification templates. Grafana's default templates are based on the [Go templating system](https://golang.org/pkg/text/template) where some fields are evaluated as text, while others are evaluated as HTML (which can affect escaping). The default template, defined in [default_template.go](https://github.com/grafana/alerting/blob/main/alerting/notifier/channels/default_template.go), is a useful reference for custom templates. -Since most of the contact point fields can be templated, you can create reusable custom templates and use them in multiple contact points. The default template is defined in [default_template.go](https://github.com/grafana/alerting/blob/main/alerting/notifier/channels/default_template.go) which can serve as a useful reference or starting point for custom templates. +Since most of the contact point fields can be templated, you can create reusable custom templates and use them in multiple contact points. ### Using templates diff --git a/docs/sources/alerting/manage-notifications/alertmanager.md b/docs/sources/alerting/manage-notifications/alertmanager.md index 1bc4e62fca3..336ed301c3e 100644 --- a/docs/sources/alerting/manage-notifications/alertmanager.md +++ b/docs/sources/alerting/manage-notifications/alertmanager.md @@ -37,6 +37,8 @@ Here are two examples of when you may want to configure your own external alertm Alertmanagers are visible from the drop-down menu on the Alerting Contact Points, Notification Policies, and Silences pages. +If you are provisioning your data source, set the flag `handleGrafanaManagedAlerts` in the `jsonData` field to `true` to send Grafana-managed alerts to this Alertmanager. + **Useful links** [Prometheus Alertmanager documentation](https://prometheus.io/docs/alerting/latest/alertmanager/) diff --git a/docs/sources/alerting/performance-limitations/index.md b/docs/sources/alerting/performance-limitations/index.md index e108dd1ce0c..f8e17c8715d 100644 --- a/docs/sources/alerting/performance-limitations/index.md +++ b/docs/sources/alerting/performance-limitations/index.md @@ -23,7 +23,7 @@ The following section provides a list of alerting performance considerations. - Cardinality of the rule's result set. For example, suppose you are monitoring API response errors for every API path, on every VM in your fleet. This set has a cardinality of _n_ number of paths multiplied by _v_ number of VMs. You can reduce the cardinality of a result set - perhaps by monitoring errors-per-VM instead of for each path per VM. - Complexity of the alerting query consideration. Queries that data sources can process and respond to quickly consume fewer resources. Although this consideration is less important than the other considerations listed above, if you have reduced those as much as possible, looking at individual query performance could make a difference. -Each evaluation of an alert rule generates a set of alert instances; one for each member of the result set. The state of all the instances is written to the `alert_instance` table in Grafana's SQL database. +Each evaluation of an alert rule generates a set of alert instances; one for each member of the result set. The state of all the instances is written to the `alert_instance` table in Grafana's SQL database. This number of write-heavy operations can cause issues when using SQLite. Grafana Alerting exposes a metric, `grafana_alerting_rule_evaluations_total` that counts the number of alert rule evaluations. To get a feel for the influence of rule evaluations on your Grafana instance, you can observe the rate of evaluations and compare it with resource consumption. In a Prometheus-compatible database, you can use the query `rate(grafana_alerting_rule_evaluations_total[5m])` to compute the rate over 5 minute windows of time. It's important to remember that this isn't the full picture of rule evaluation. For example, the load will be unevenly distributed if you have some rules that evaluate every 10 seconds, and others every 30 minutes. diff --git a/docs/sources/alerting/set-up/configure-alertmanager/index.md b/docs/sources/alerting/set-up/configure-alertmanager/index.md index 166fd3013d9..f0eceb9ae56 100644 --- a/docs/sources/alerting/set-up/configure-alertmanager/index.md +++ b/docs/sources/alerting/set-up/configure-alertmanager/index.md @@ -29,6 +29,8 @@ To add an external Alertmanager, complete the following steps. 2. Search for Alertmanager. 3. Choose your Implementation and fill out the fields on the page, as required. +If you are provisioning your data source, set the flag `handleGrafanaManagedAlerts` in the `jsonData` field to `true` to send Grafana-managed alerts to this Alertmanager. + **Note:** Prometheus, Grafana Mimir, and Cortex implementations of Alertmanager are supported. For Prometheus, contact points and notification policies are read-only in the Grafana Alerting UI. diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index 1aec13bc0e4..82b1c05ae20 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -22,20 +22,20 @@ Details on how to set up the files and which fields are required for each object **Note:** -Provisioning takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Alerting provisioning API](https://grafana.com/docs/grafana/latest/developers/http_api/admin/#reload-provisioning-configurations). +Provisioning takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API](https://grafana.com/docs/grafana/latest/developers/http_api/admin/#reload-provisioning-configurations). ### Provision alert rules Create or delete alert rules in your Grafana instance(s). -1. Create an alert rule in Grafana. -1. Use the [Alerting provisioning API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#route-get-alert-rule) to extract the alert rule. +1. Create alert rules in Grafana. +1. Use the [Alerting provisioning API](https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/#route-get-alert-rule-export) export endpoints to download a provisioning file for your alert rules. 1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. Example configuration files can be found below. 1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). -1. Delete the alert rule in Grafana. +1. Delete the alert rules in Grafana that will be provisioned. **Note:** diff --git a/docs/sources/dashboards/create-reports/index.md b/docs/sources/dashboards/create-reports/index.md index 2ddd5e6673e..7def573355e 100644 --- a/docs/sources/dashboards/create-reports/index.md +++ b/docs/sources/dashboards/create-reports/index.md @@ -181,7 +181,7 @@ You can send a report email with an image of the dashboard embedded in the email You can generate and save PDF files of any dashboard. -> **Note:** Available in [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}). +> **Note:** Available in [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}) version 6.7 and later, and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). 1. In the upper-right corner of the dashboard that you want to export as PDF, click the **Share dashboard** icon. 1. On the PDF tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. diff --git a/docs/sources/datasources/azure-monitor/_index.md b/docs/sources/datasources/azure-monitor/_index.md index 227b4414e27..175fba96596 100644 --- a/docs/sources/datasources/azure-monitor/_index.md +++ b/docs/sources/datasources/azure-monitor/_index.md @@ -155,4 +155,4 @@ Until Grafana v8.0, you could query the same Azure Application Insights data usi These queries were deprecated in Grafana v7.5. In Grafana v8.0, Application Insights and Insights Analytics were made read-only in favor of querying this data through Metrics and Logs. These query methods were completely removed in Grafana v9.0. -If you're upgrading from a Grafana version prior to v9.0 and relied on Application Insights and Analytics queries, refer to the [Grafana v9.0 documentation](/v9.0/datasources/azuremonitor/deprecated-application-insights/) for help migrating these queries to Metrics and Logs queries. +If you're upgrading from a Grafana version prior to v9.0 and relied on Application Insights and Analytics queries, refer to the [Grafana v9.0 documentation](/docs/grafana/v9.0/datasources/azuremonitor/deprecated-application-insights/) for help migrating these queries to Metrics and Logs queries. diff --git a/docs/sources/datasources/jaeger/_index.md b/docs/sources/datasources/jaeger/_index.md index 4441dc5fc61..0d2bcb14636 100644 --- a/docs/sources/datasources/jaeger/_index.md +++ b/docs/sources/datasources/jaeger/_index.md @@ -154,7 +154,7 @@ datasources: tags: [{ key: 'service.name', value: 'service' }, { key: 'job' }] queries: - name: 'Sample query' - query: 'sum(rate(tempo_spanmetrics_latency_bucket{$__tags}[5m]))' + query: 'sum(rate(traces_spanmetrics_latency_bucket{$__tags}[5m]))' secureJsonData: basicAuthPassword: my_password ``` diff --git a/docs/sources/datasources/tempo/_index.md b/docs/sources/datasources/tempo/_index.md index 124e273f6c3..419f4f78e1c 100644 --- a/docs/sources/datasources/tempo/_index.md +++ b/docs/sources/datasources/tempo/_index.md @@ -175,7 +175,7 @@ datasources: tags: [{ key: 'service.name', value: 'service' }, { key: 'job' }] queries: - name: 'Sample query' - query: 'sum(rate(tempo_spanmetrics_latency_bucket{$__tags}[5m]))' + query: 'sum(rate(traces_spanmetrics_latency_bucket{$__tags}[5m]))' serviceMap: datasourceUid: 'prometheus' search: diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developers/http_api/alerting_provisioning.md index 1198c17eead..98ff5686667 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developers/http_api/alerting_provisioning.md @@ -18,7 +18,7 @@ title: 'Alerting Provisioning HTTP API ' ### Version -1.0.0 +1.1.0 ## Content negotiation @@ -29,53 +29,61 @@ title: 'Alerting Provisioning HTTP API ' ### Produces - application/json +- text/yaml +- application/yaml ## All endpoints ### Alert rules -| Method | URI | Name | Summary | -| ------ | ----------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------ | -| GET | /api/v1/provisioning/alert-rules/{UID} | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | -| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | -| PUT | /api/v1/provisioning/alert-rules/{UID} | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | -| PUT | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group. | -| DELETE | /api/v1/provisioning/alert-rules/{UID} | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | +| Method | URI | Name | Summary | +| ------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------- | +| DELETE | /api/v1/provisioning/alert-rules/{UID} | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | +| GET | /api/v1/provisioning/alert-rules/{UID} | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | +| GET | /api/v1/provisioning/alert-rules/{UID}/export | [route get alert rule export](#route-get-alert-rule-export) | Export an alert rule in provisioning file format. | +| GET | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} | [route get alert rule group](#route-get-alert-rule-group) | Get a rule group. | +| GET | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export | [route get alert rule group export](#route-get-alert-rule-group-export) | Export an alert rule group in provisioning file format. | +| GET | /api/v1/provisioning/alert-rules | [route get alert rules](#route-get-alert-rules) | Get all the alert rules. | +| GET | /api/v1/provisioning/alert-rules/export | [route get alert rules export](#route-get-alert-rules-export) | Export all alert rules in provisioning file format. | +| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | +| PUT | /api/v1/provisioning/alert-rules/{UID} | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | +| PUT | /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group. | ### Contact points | Method | URI | Name | Summary | | ------ | ----------------------------------------- | --------------------------------------------------------- | --------------------------------- | +| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | | GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | | POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | | PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | -| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | ### Notification policies -| Method | URI | Name | Summary | -| ------ | ----------------------------- | ----------------------------------------------- | ---------------------------------- | -| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | -| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | +| Method | URI | Name | Summary | +| ------ | ----------------------------- | --------------------------------------------------- | ------------------------------------ | +| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. | +| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | +| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | ### Mute timings | Method | URI | Name | Summary | | ------ | ---------------------------------------- | ----------------------------------------------------- | -------------------------------- | -| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | +| DELETE | /api/v1/provisioning/mute-timings/{name} | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | | GET | /api/v1/provisioning/mute-timings/{name} | [route get mute timing](#route-get-mute-timing) | Get a mute timing. | +| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | | POST | /api/v1/provisioning/mute-timings | [route post mute timing](#route-post-mute-timing) | Create a new mute timing. | | PUT | /api/v1/provisioning/mute-timings/{name} | [route put mute timing](#route-put-mute-timing) | Replace an existing mute timing. | -| DELETE | /api/v1/provisioning/mute-timings/{name} | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | ### Templates -| Method | URI | Name | Summary | -| ------ | ------------------------------------- | ----------------------------------------------- | ------------------------------- | -| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | -| GET | /api/v1/provisioning/templates/{name} | [route get template](#route-get-template) | Get a notification template. | -| PUT | /api/v1/provisioning/templates/{name} | [route put template](#route-put-template) | Creates or updates a template. | -| DELETE | /api/v1/provisioning/templates/{name} | [route delete template](#route-delete-template) | Delete a template. | +| Method | URI | Name | Summary | +| ------ | ------------------------------------- | ----------------------------------------------- | ------------------------------------------ | +| DELETE | /api/v1/provisioning/templates/{name} | [route delete template](#route-delete-template) | Delete a template. | +| GET | /api/v1/provisioning/templates/{name} | [route get template](#route-get-template) | Get a notification template. | +| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | +| PUT | /api/v1/provisioning/templates/{name} | [route put template](#route-put-template) | Updates an existing notification template. | ## Paths @@ -87,16 +95,15 @@ DELETE /api/v1/provisioning/alert-rules/{UID} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | -| UID | `path` | string | `string` | | ✓ | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | #### All responses -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ----------- | ---------------------------------------- | :---------: | --------------------------------------------- | -| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | -| [400](#route-delete-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-delete-alert-rule-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ---------- | ---------------------------------------- | :---------: | --------------------------------------------- | +| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | #### Responses @@ -106,14 +113,6 @@ Status: No Content ###### Schema -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - ### Delete a contact point. (_RouteDeleteContactpoints_) ``` @@ -126,34 +125,23 @@ DELETE /api/v1/provisioning/contact-points/{UID} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | UID should be the contact point unique identifier | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------ | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | #### All responses -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------------ | -| [202](#route-delete-contactpoints-202) | Accepted | Ack | | [schema](#route-delete-contactpoints-202-schema) | -| [400](#route-delete-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-delete-contactpoints-400-schema) | +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | ---------- | ------------------------------------------- | :---------: | ------------------------------------------------ | +| [204](#route-delete-contactpoints-204) | No Content | The contact point was deleted successfully. | | [schema](#route-delete-contactpoints-204-schema) | #### Responses -##### 202 - Ack +##### 204 - The contact point was deleted successfully. -Status: Accepted +Status: No Content -###### Schema - -[Ack](#ack) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) +###### Schema ### Delete a mute timing. (_RouteDeleteMuteTiming_) @@ -163,26 +151,24 @@ DELETE /api/v1/provisioning/mute-timings/{name} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | #### All responses -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ---------- | ----------- | :---------: | ---------------------------------------------- | -| [204](#route-delete-mute-timing-204) | No Content | Ack | | [schema](#route-delete-mute-timing-204-schema) | +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ---------- | ----------------------------------------- | :---------: | ---------------------------------------------- | +| [204](#route-delete-mute-timing-204) | No Content | The mute timing was deleted successfully. | | [schema](#route-delete-mute-timing-204-schema) | #### Responses -##### 204 - Ack +##### 204 - The mute timing was deleted successfully. Status: No Content ###### Schema -[Ack](#ack) - ### Delete a template. (_RouteDeleteTemplate_) ``` @@ -197,20 +183,18 @@ DELETE /api/v1/provisioning/templates/{name} #### All responses -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ---------- | ----------- | :---------: | ------------------------------------------- | -| [204](#route-delete-template-204) | No Content | Ack | | [schema](#route-delete-template-204-schema) | +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ---------- | -------------------------------------- | :---------: | ------------------------------------------- | +| [204](#route-delete-template-204) | No Content | The template was deleted successfully. | | [schema](#route-delete-template-204-schema) | #### Responses -##### 204 - Ack +##### 204 - The template was deleted successfully. Status: No Content ###### Schema -[Ack](#ack) - ### Get a specific alert rule by UID. (_RouteGetAlertRule_) ``` @@ -219,34 +203,210 @@ GET /api/v1/provisioning/alert-rules/{UID} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | -| UID | `path` | string | `string` | | ✓ | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | #### All responses -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------ | -| [200](#route-get-alert-rule-200) | OK | AlertRule | | [schema](#route-get-alert-rule-200-schema) | -| [400](#route-get-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-get-alert-rule-400-schema) | +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | --------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-get-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-get-alert-rule-200-schema) | +| [404](#route-get-alert-rule-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-404-schema) | #### Responses -##### 200 - AlertRule +##### 200 - ProvisionedAlertRule Status: OK ###### Schema -[AlertRule](#alert-rule) +[ProvisionedAlertRule](#provisioned-alert-rule) -##### 400 - ValidationError +##### 404 - Not found. -Status: Bad Request +Status: Not Found -###### Schema +###### Schema -[ValidationError](#validation-error) +### Export an alert rule in provisioning file format. (_RouteGetAlertRuleExport_) + +``` +GET /api/v1/provisioning/alert-rules/{UID}/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | ------- | -------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------- | +| [200](#route-get-alert-rule-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-export-200-schema) | +| [404](#route-get-alert-rule-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get a rule group. (_RouteGetAlertRuleGroup_) + +``` +GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | --------- | -------------- | :---------: | ------------------------------------------------ | +| [200](#route-get-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-get-alert-rule-group-200-schema) | +| [404](#route-get-alert-rule-group-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-404-schema) | + +#### Responses + +##### 200 - AlertRuleGroup + +Status: OK + +###### Schema + +[AlertRuleGroup](#alert-rule-group) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Export an alert rule group in provisioning file format. (_RouteGetAlertRuleGroupExport_) + +``` +GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------- | ------- | -------- | --------- | :------: | ------- | -------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------------- | +| [200](#route-get-alert-rule-group-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-group-export-200-schema) | +| [404](#route-get-alert-rule-group-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get all the alert rules. (_RouteGetAlertRules_) + +``` +GET /api/v1/provisioning/alert-rules +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | --------------------- | :---------: | ------------------------------------------- | +| [200](#route-get-alert-rules-200) | OK | ProvisionedAlertRules | | [schema](#route-get-alert-rules-200-schema) | + +#### Responses + +##### 200 - ProvisionedAlertRules + +Status: OK + +###### Schema + +[ProvisionedAlertRules](#provisioned-alert-rules) + +### Export all alert rules in provisioning file format. (_RouteGetAlertRulesExport_) + +``` +GET /api/v1/provisioning/alert-rules/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | ------- | --------- | :------: | ------- | -------------------------------------------------- | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | +| [200](#route-get-alert-rules-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rules-export-200-schema) | +| [404](#route-get-alert-rules-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rules-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema ### Get all the contact points. (_RouteGetContactpoints_) @@ -254,30 +414,27 @@ Status: Bad Request GET /api/v1/provisioning/contact-points ``` +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------- | ------ | -------- | --------- | :------: | ------- | -------------- | +| name | `query` | string | `string` | | | | Filter by name | + #### All responses -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ----------- | --------------- | :---------: | --------------------------------------------- | -| [200](#route-get-contactpoints-200) | OK | Route | | [schema](#route-get-contactpoints-200-schema) | -| [400](#route-get-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-get-contactpoints-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ------ | ------------- | :---------: | --------------------------------------------- | +| [200](#route-get-contactpoints-200) | OK | ContactPoints | | [schema](#route-get-contactpoints-200-schema) | #### Responses -##### 200 - Route +##### 200 - ContactPoints Status: OK ###### Schema -[Route](#route) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) +[ContactPoints](#contact-points) ### Get a mute timing. (_RouteGetMuteTiming_) @@ -287,16 +444,16 @@ GET /api/v1/provisioning/mute-timings/{name} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | #### All responses -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | ---------------- | :---------: | ------------------------------------------- | -| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | -| [400](#route-get-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-get-mute-timing-400-schema) | +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | --------- | ---------------- | :---------: | ------------------------------------------- | +| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | +| [404](#route-get-mute-timing-404) | Not Found | Not found. | | [schema](#route-get-mute-timing-404-schema) | #### Responses @@ -308,13 +465,11 @@ Status: OK [MuteTimeInterval](#mute-time-interval) -##### 400 - ValidationError +##### 404 - Not found. -Status: Bad Request +Status: Not Found -###### Schema - -[ValidationError](#validation-error) +###### Schema ### Get all the mute timings. (_RouteGetMuteTimings_) @@ -324,10 +479,9 @@ GET /api/v1/provisioning/mute-timings #### All responses -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ----------- | --------------- | :---------: | -------------------------------------------- | -| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | -| [400](#route-get-mute-timings-400) | Bad Request | ValidationError | | [schema](#route-get-mute-timings-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ------ | ----------- | :---------: | -------------------------------------------- | +| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | #### Responses @@ -339,14 +493,6 @@ Status: OK [MuteTimings](#mute-timings) -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - ### Get the notification policy tree. (_RouteGetPolicyTree_) ``` @@ -355,10 +501,9 @@ GET /api/v1/provisioning/policies #### All responses -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | -| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | -| [400](#route-get-policy-tree-400) | Bad Request | ValidationError | | [schema](#route-get-policy-tree-400-schema) | +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | ----------- | :---------: | ------------------------------------------- | +| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | #### Responses @@ -370,14 +515,6 @@ Status: OK [Route](#route) -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - ### Get a notification template. (_RouteGetTemplate_) ``` @@ -395,7 +532,7 @@ GET /api/v1/provisioning/templates/{name} | Code | Status | Description | Has headers | Schema | | ------------------------------ | --------- | -------------------- | :---------: | ---------------------------------------- | | [200](#route-get-template-200) | OK | NotificationTemplate | | [schema](#route-get-template-200-schema) | -| [404](#route-get-template-404) | Not Found | NotFound | | [schema](#route-get-template-404-schema) | +| [404](#route-get-template-404) | Not Found | Not found. | | [schema](#route-get-template-404-schema) | #### Responses @@ -405,16 +542,14 @@ Status: OK ###### Schema -[NotificationTemplate](#message-template) +[NotificationTemplate](#notification-template) -##### 404 - NotFound +##### 404 - Not found. Status: Not Found ###### Schema -[NotFound](#not-found) - ### Get all notification templates. (_RouteGetTemplates_) ``` @@ -423,28 +558,26 @@ GET /api/v1/provisioning/templates #### All responses -| Code | Status | Description | Has headers | Schema | -| ------------------------------- | ----------- | -------------------- | :---------: | ----------------------------------------- | -| [200](#route-get-templates-200) | OK | NotificationTemplate | | [schema](#route-get-templates-200-schema) | -| [400](#route-get-templates-400) | Bad Request | ValidationError | | [schema](#route-get-templates-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ------------------------------- | --------- | --------------------- | :---------: | ----------------------------------------- | +| [200](#route-get-templates-200) | OK | NotificationTemplates | | [schema](#route-get-templates-200-schema) | +| [404](#route-get-templates-404) | Not Found | Not found. | | [schema](#route-get-templates-404-schema) | #### Responses -##### 200 - NotificationTemplate +##### 200 - NotificationTemplates Status: OK ###### Schema -[NotificationTemplate](#message-template) +[NotificationTemplates](#notification-templates) -##### 400 - ValidationError +##### 404 - Not found. -Status: Bad Request +Status: Not Found -###### Schema - -[ValidationError](#validation-error) +###### Schema ### Create a new alert rule. (_RoutePostAlertRule_) @@ -452,28 +585,33 @@ Status: Bad Request POST /api/v1/provisioning/alert-rules ``` +#### Consumes + +- application/json + #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------------------------ | ------------------ | --------- | :------: | ------- | ----------- | -| Body | `body` | [AlertRule](#alert-rule) | `models.AlertRule` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | ----------- | +| X-Disable-Provenance | `header` | string | `string` | | | | | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | #### All responses -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | -| [201](#route-post-alert-rule-201) | Created | AlertRule | | [schema](#route-post-alert-rule-201-schema) | -| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------- | +| [201](#route-post-alert-rule-201) | Created | ProvisionedAlertRule | | [schema](#route-post-alert-rule-201-schema) | +| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | #### Responses -##### 201 - AlertRule +##### 201 - ProvisionedAlertRule Status: Created ###### Schema -[AlertRule](#alert-rule) +[ProvisionedAlertRule](#provisioned-alert-rule) ##### 400 - ValidationError @@ -501,20 +639,20 @@ POST /api/v1/provisioning/contact-points #### All responses -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ----------- | --------------- | :---------: | ---------------------------------------------- | -| [202](#route-post-contactpoints-202) | Accepted | Ack | | [schema](#route-post-contactpoints-202-schema) | -| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------------- | +| [202](#route-post-contactpoints-202) | Accepted | EmbeddedContactPoint | | [schema](#route-post-contactpoints-202-schema) | +| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | #### Responses -##### 202 - Ack +##### 202 - EmbeddedContactPoint Status: Accepted ###### Schema -[Ack](#ack) +[EmbeddedContactPoint](#embedded-contact-point) ##### 400 - ValidationError @@ -577,27 +715,28 @@ PUT /api/v1/provisioning/alert-rules/{UID} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------------------------ | ------------------ | --------- | :------: | ------- | ----------- | -| UID | `path` | string | `string` | | ✓ | | | -| Body | `body` | [AlertRule](#alert-rule) | `models.AlertRule` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | -------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| X-Disable-Provenance | `header` | string | `string` | | | | | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | #### All responses -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------ | -| [200](#route-put-alert-rule-200) | OK | AlertRule | | [schema](#route-put-alert-rule-200-schema) | -| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-put-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-put-alert-rule-200-schema) | +| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | #### Responses -##### 200 - AlertRule +##### 200 - ProvisionedAlertRule Status: OK ###### Schema -[AlertRule](#alert-rule) +[ProvisionedAlertRule](#provisioned-alert-rule) ##### 400 - ValidationError @@ -662,10 +801,10 @@ PUT /api/v1/provisioning/contact-points/{UID} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | ------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | UID should be the contact point unique identifier | -| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | ------------------------------------------ | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | +| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | #### All responses @@ -704,10 +843,10 @@ PUT /api/v1/provisioning/mute-timings/{name} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | --------------------------------------- | ------------------------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | -| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | --------------------------------------- | ------------------------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | +| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | #### All responses @@ -746,9 +885,9 @@ PUT /api/v1/provisioning/policies #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | --------------- | -------------- | --------- | :------: | ------- | ----------- | -| Body | `body` | [Route](#route) | `models.Route` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | --------------- | -------------- | --------- | :------: | ------- | ---------------------------------------- | +| Body | `body` | [Route](#route) | `models.Route` | | | | The new notification routing tree to use | #### All responses @@ -775,7 +914,7 @@ Status: Bad Request [ValidationError](#validation-error) -### Updates an existing template. (_RoutePutTemplate_) +### Updates an existing notification template. (_RoutePutTemplate_) ``` PUT /api/v1/provisioning/templates/{name} @@ -787,27 +926,27 @@ PUT /api/v1/provisioning/templates/{name} #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | -------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | -| Body | `body` | [NotificationTemplateContent](#message-template-content) | `models.NotificationTemplateContent` | | | | | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | ------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | +| Body | `body` | [NotificationTemplateContent](#notification-template-content) | `models.NotificationTemplateContent` | | | | | #### All responses -| Code | Status | Description | Has headers | Schema | -| ------------------------------ | ----------- | --------------- | :---------: | ---------------------------------------- | -| [202](#route-put-template-202) | Accepted | Ack | | [schema](#route-put-template-202-schema) | -| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | +| Code | Status | Description | Has headers | Schema | +| ------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------- | +| [202](#route-put-template-202) | Accepted | NotificationTemplate | | [schema](#route-put-template-202-schema) | +| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | #### Responses -##### 202 - Ack +##### 202 - NotificationTemplate Status: Accepted ###### Schema -[Ack](#ack) +[NotificationTemplate](#notification-template) ##### 400 - ValidationError @@ -817,57 +956,116 @@ Status: Bad Request [ValidationError](#validation-error) +### Clears the notification policy tree. (_RouteResetPolicyTree_) + +``` +DELETE /api/v1/provisioning/policies +``` + +#### Consumes + +- application/json + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | -------- | ----------- | :---------: | --------------------------------------------- | +| [202](#route-reset-policy-tree-202) | Accepted | Ack | | [schema](#route-reset-policy-tree-202-schema) | + +#### Responses + +##### 202 - Ack + +Status: Accepted + +###### Schema + +[Ack](#ack) + +## Models + +### Ack + +[interface{}](#interface) + ### AlertQuery **Properties** | Name | Type | Go type | Required | Default | Description | Example | | --------------------------------------------------------- | ----------------------------------------- | ------------------- | :------: | ------- | -------------------------------------------------------------------------------------------------- | ------- | -| DatasourceUID | string | `string` | | | Grafana data source unique identifier; it should be '-100' for a Server Side Expression operation. | | -| Model | object | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | -| QueryType | string | `string` | | | QueryType is an optional identifier for the type of query. | +| datasourceUid | string | `string` | | | Grafana data source unique identifier; it should be '-100' for a Server Side Expression operation. | | +| model | [interface{}](#interface) | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | +| queryType | string | `string` | | | QueryType is an optional identifier for the type of query. | | It can be used to distinguish different types of queries. | | -| RefID | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | +| refId | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | | relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | -### AlertRule +### AlertQueryExport **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | -| Condition | string | `string` | ✓ | | | `A` | -| Data | [][alertquery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"-100","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | -| ExecErrState | string | `string` | ✓ | | Allowed values: "OK", "Alerting", "Error" | | -| FolderUID | string | `string` | ✓ | | | `project_x` | -| ID | int64 (formatted integer) | `int64` | | | | | -| Labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | -| NoDataState | string | `string` | ✓ | | Allowed values: "OK", "NoData", "Error" | | -| OrgID | int64 (formatted integer) | `int64` | ✓ | | | | -| RuleGroup | string | `string` | ✓ | | | `eval_group_1` | -| Title | string | `string` | ✓ | | | `Always firing` | -| UID | string | `string` | | | | | -| Updated | date-time (formatted string) | `strfmt.DateTime` | | | | | -| for | [Duration](#duration) | `Duration` | ✓ | | | | -| provenance | string | `Provenance` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ----------------- | ----------------------------------------- | ------------------- | :------: | ------- | ----------- | ------- | +| datasourceUid | string | `string` | | | | | +| model | [interface{}](#interface) | `interface{}` | | | | | +| queryType | string | `string` | | | | | +| refId | string | `string` | | | | | +| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | + +### AlertRuleExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ----------------------------------------- | --------------------- | :------: | ------- | ----------- | ------- | +| annotations | map of string | `map[string]string` | | | | | +| condition | string | `string` | | | | | +| dasboardUid | string | `string` | | | | | +| data | [][alertqueryexport](#alert-query-export) | `[]*AlertQueryExport` | | | | | +| execErrState | string | `string` | | | | | +| for | [Duration](#duration) | `Duration` | | | | | +| labels | map of string | `map[string]string` | | | | | +| noDataState | string | `string` | | | | | +| panelId | int64 (formatted integer) | `int64` | | | | | +| title | string | `string` | | | | | +| uid | string | `string` | | | | | ### AlertRuleGroup **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| Interval | int64 (formatted integer) | `int64` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | +| folderUid | string | `string` | | | | | +| interval | int64 (formatted integer) | `int64` | | | | | +| rules | [][provisionedalertrule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | +| title | string | `string` | | | | | -### DayOfMonthRange +### AlertRuleGroupExport **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| Begin | int64 (formatted integer) | `int64` | | | | | -| End | int64 (formatted integer) | `int64` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | --------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | +| folder | string | `string` | | | | | +| interval | [Duration](#duration) | `Duration` | | | | | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| rules | [][alertruleexport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | + +### AlertingFileExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ---------- | -------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | +| apiVersion | int64 (formatted integer) | `int64` | | | | | +| groups | [][alertrulegroupexport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | + +### ContactPoints + +[][embeddedcontactpoint](#embedded-contact-point) ### Duration @@ -877,25 +1075,35 @@ Status: Bad Request ### EmbeddedContactPoint -> EmbeddedContactPoint is the contact point integration that is used +> EmbeddedContactPoint is the contact point type that is used > by grafanas embedded alertmanager implementation. **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| --------------------- | ------- | -------- | :------: | ------- | ---------------------------------------------------------------------------------------------------- | ----------------------- | -| DisableResolveMessage | boolean | `bool` | | | | `false` | -| Name | string | `string` | ✓ | | Name is used as grouping key in the UI. Contact points with the same name will be grouped in the UI. | `webhook_1` | -| Provenance | string | `string` | | | | | -| Type | string | `string` | ✓ | | | `webhook` | -| UID | string | `string` | | | UID is the unique identifier of the contact point. The UID can be set by the user. | `my_external_reference` | -| settings | object | `JSON` | ✓ | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------------------------ | ----------------------- | -------- | :------: | ------- | ----------------------------------------------------------------- | --------- | +| disableResolveMessage | boolean | `bool` | | | | `false` | +| name | string | `string` | | | Name is used as grouping key in the UI. Contact points with the | +| same name will be grouped in the UI. | `webhook_1` | +| provenance | string | `string` | | | | | +| settings | [JSON](#json) | `JSON` | ✓ | | | | +| type | string | `string` | ✓ | | | `webhook` | +| uid | string | `string` | | | UID is the unique identifier of the contact point. The UID can be | +| set by the user. | `my_external_reference` | + +### Json + +[interface{}](#interface) + +### MatchRegexps + +[MatchRegexps](#match-regexps) ### MatchType -| Name | Type | Go type | Default | Description | Example | -| --------- | ------------------------- | ------- | ------- | ---------------------------------------------------------------------- | ------- | -| MatchType | int64 (formatted integer) | int64 | | 0 = MatchEqual, 1 = MatchNotEqual, 2 = MatchRegexp, 3 = MatchNotRegexp | | +| Name | Type | Go type | Default | Description | Example | +| --------- | ------------------------- | ------- | ------- | ----------- | ------- | +| MatchType | int64 (formatted integer) | int64 | | | | ### Matcher @@ -915,49 +1123,40 @@ Status: Bad Request [][matcher](#matcher) -### NotificationTemplate - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ---------- | ------ | ------------ | :------: | ------- | ----------- | ------- | -| Name | string | `string` | | | | | -| Template | string | `string` | | | | | -| provenance | string | `Provenance` | | | | | - -### NotificationTemplateContent - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | -| Template | string | `string` | | | | | - -### MonthRange - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| Begin | int64 (formatted integer) | `int64` | | | | | -| End | int64 (formatted integer) | `int64` | | | | | - ### MuteTimeInterval **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | -| Name | string | `string` | | | | | -| TimeIntervals | [][timeinterval](#time-interval) | `[]*TimeInterval` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| time_intervals | [][timeinterval](#time-interval) | `[]*TimeInterval` | | | | | ### MuteTimings [][mutetimeinterval](#mute-time-interval) -### NotFound +### NotificationTemplate -[interface{}](#interface) +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ---------- | ------------------------- | ------------ | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| template | string | `string` | | | | | + +### NotificationTemplateContent + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | +| template | string | `string` | | | | | + +### NotificationTemplates + +[][notificationtemplate](#notification-template) ### ObjectMatchers @@ -965,6 +1164,45 @@ Status: Bad Request #### Inlined models +### Provenance + +| Name | Type | Go type | Default | Description | Example | +| ---------- | ------ | ------- | ------- | ----------- | ------- | +| Provenance | string | string | | | | + +### ProvisionedAlertRule + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | +| condition | string | `string` | ✓ | | | `A` | +| data | [][alertquery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"-100","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | +| execErrState | string | `string` | ✓ | | | | +| folderUID | string | `string` | ✓ | | | `project_x` | +| for | [Duration](#duration) | `Duration` | ✓ | | | | +| id | int64 (formatted integer) | `int64` | | | | | +| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | +| noDataState | string | `string` | ✓ | | | | +| orgID | int64 (formatted integer) | `int64` | ✓ | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| ruleGroup | string | `string` | ✓ | | | `eval_group_1` | +| title | string | `string` | ✓ | | | `Always firing` | +| uid | string | `string` | | | | | +| updated | date-time (formatted string) | `strfmt.DateTime` | | | | | + +### ProvisionedAlertRules + +[][provisionedalertrule](#provisioned-alert-rule) + +### Regexp + +> A Regexp is safe for concurrent use by multiple goroutines, +> except for configuration methods, such as Longest. + +[interface{}](#interface) + ### RelativeTimeRange > RelativeTimeRange is the per query start and end time @@ -979,22 +1217,26 @@ Status: Bad Request ### Route -> A Route is a node that contains definitions of how to handle alerts. +> A Route is a node that contains definitions of how to handle alerts. This is modified +> from the upstream alertmanager in that it adds the ObjectMatchers property. **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ----------------- | ---------------------------------- | ---------------- | :------: | ------- | ----------- | ------- | -| Continue | boolean | `bool` | | | | | -| GroupByStr | []string | `[]string` | | | | | -| MuteTimeIntervals | []string | `[]string` | | | | | -| Receiver | string | `string` | | | | | -| Routes | [][route](#route) | `[]*Route` | | | | | -| group_interval | [Duration](#duration) | `Duration` | | | | | -| group_wait | [Duration](#duration) | `Duration` | | | | | -| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | -| provenance | string | `Provenance` | | | | | -| repeat_interval | [Duration](#duration) | `Duration` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | +| continue | boolean | `bool` | | | | | +| group_by | []string | `[]string` | | | | | +| group_interval | string | `string` | | | | | +| group_wait | string | `string` | | | | | +| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | +| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | +| matchers | [Matchers](#matchers) | `Matchers` | | | | | +| mute_time_intervals | []string | `[]string` | | | | | +| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| receiver | string | `string` | | | | | +| repeat_interval | string | `string` | | | | | +| routes | [][route](#route) | `[]*Route` | | | | | ### TimeInterval @@ -1003,13 +1245,14 @@ Status: Bad Request **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ----------- | ---------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | -| DaysOfMonth | [][dayofmonthrange](#day-of-month-range) | `[]*DayOfMonthRange` | | | | | -| Months | [][monthrange](#month-range) | `[]*MonthRange` | | | | | -| Times | [][timerange](#time-range) | `[]*TimeRange` | | | | | -| Weekdays | [][weekdayrange](#weekday-range) | `[]*WeekdayRange` | | | | | -| Years | [][yearrange](#year-range) | `[]*YearRange` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- | +| days_of_month | []string | `[]string` | | | | | +| location | string | `string` | | | | | +| months | []string | `[]string` | | | | | +| times | [][timerange](#time-range) | `[]*TimeRange` | | | | | +| weekdays | []string | `[]string` | | | | | +| years | []string | `[]string` | | | | | ### TimeRange @@ -1026,24 +1269,6 @@ Status: Bad Request **Properties** -| Name | Type | Go type | Required | Default | Description | Example | -| ---- | ------ | -------- | :------: | ------- | ----------- | ------- | -| Msg | string | `string` | | | | | - -### WeekdayRange - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| Begin | int64 (formatted integer) | `int64` | | | | | -| End | int64 (formatted integer) | `int64` | | | | | - -### YearRange - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| Begin | int64 (formatted integer) | `int64` | | | | | -| End | int64 (formatted integer) | `int64` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ---- | ------ | -------- | :------: | ------- | ----------- | --------------- | +| msg | string | `string` | | | | `error message` | diff --git a/docs/sources/developers/kinds/composable/nodegraphpanelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/nodegraphpanelcfg/schema-reference.md new file mode 100644 index 00000000000..e0b84e4862e --- /dev/null +++ b/docs/sources/developers/kinds/composable/nodegraphpanelcfg/schema-reference.md @@ -0,0 +1,97 @@ +--- +keywords: + - grafana + - schema +title: NodeGraphPanelCfg kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# NodeGraphPanelCfg kind + +## Maturity: experimental +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|----------------|-------------------------|----------|-------------| +| `ArcOption` | [object](#arcoption) | **Yes** | | +| `EdgeOptions` | [object](#edgeoptions) | **Yes** | | +| `NodeOptions` | [object](#nodeoptions) | **Yes** | | +| `PanelOptions` | [object](#paneloptions) | **Yes** | | + +## ArcOption + +### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-----------------------------------------------------------------------------------------------------| +| `color` | string | No | The color of the arc. | +| `field` | string | No | Field from which to get the value. Values should be less than 1, representing fraction of a circle. | + +## EdgeOptions + +### Properties + +| Property | Type | Required | Description | +|---------------------|--------|----------|-----------------------------------------------------------------------------| +| `mainStatUnit` | string | No | Unit for the main stat to override what ever is set in the data frame. | +| `secondaryStatUnit` | string | No | Unit for the secondary stat to override what ever is set in the data frame. | + +## NodeOptions + +### Properties + +| Property | Type | Required | Description | +|---------------------|---------------------------|----------|-----------------------------------------------------------------------------------------| +| `arcs` | [ArcOption](#arcoption)[] | No | Define which fields are shown as part of the node arc (colored circle around the node). | +| `mainStatUnit` | string | No | Unit for the main stat to override what ever is set in the data frame. | +| `secondaryStatUnit` | string | No | Unit for the secondary stat to override what ever is set in the data frame. | + +### ArcOption + +#### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-----------------------------------------------------------------------------------------------------| +| `color` | string | No | The color of the arc. | +| `field` | string | No | Field from which to get the value. Values should be less than 1, representing fraction of a circle. | + +## PanelOptions + +### Properties + +| Property | Type | Required | Description | +|----------|-----------------------------|----------|-------------| +| `edges` | [EdgeOptions](#edgeoptions) | No | | +| `nodes` | [NodeOptions](#nodeoptions) | No | | + +### EdgeOptions + +#### Properties + +| Property | Type | Required | Description | +|---------------------|--------|----------|-----------------------------------------------------------------------------| +| `mainStatUnit` | string | No | Unit for the main stat to override what ever is set in the data frame. | +| `secondaryStatUnit` | string | No | Unit for the secondary stat to override what ever is set in the data frame. | + +### NodeOptions + +#### Properties + +| Property | Type | Required | Description | +|---------------------|---------------------------|----------|-----------------------------------------------------------------------------------------| +| `arcs` | [ArcOption](#arcoption)[] | No | Define which fields are shown as part of the node arc (colored circle around the node). | +| `mainStatUnit` | string | No | Unit for the main stat to override what ever is set in the data frame. | +| `secondaryStatUnit` | string | No | Unit for the secondary stat to override what ever is set in the data frame. | + +#### ArcOption + +##### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-----------------------------------------------------------------------------------------------------| +| `color` | string | No | The color of the arc. | +| `field` | string | No | Field from which to get the value. Values should be less than 1, representing fraction of a circle. | + + diff --git a/docs/sources/developers/kinds/composable/statetimelinepanelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/statetimelinepanelcfg/schema-reference.md new file mode 100644 index 00000000000..326d99d4a84 --- /dev/null +++ b/docs/sources/developers/kinds/composable/statetimelinepanelcfg/schema-reference.md @@ -0,0 +1,41 @@ +--- +keywords: + - grafana + - schema +title: StateTimelinePanelCfg kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# StateTimelinePanelCfg kind + +## Maturity: experimental +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|--------------------|-----------------------------|----------|-------------| +| `PanelFieldConfig` | [object](#panelfieldconfig) | **Yes** | | +| `PanelOptions` | [object](#paneloptions) | **Yes** | | + +## PanelFieldConfig + +### Properties + +| Property | Type | Required | Description | +|---------------|---------|----------|----------------| +| `fillOpacity` | integer | No | Default: `70`. | +| `lineWidth` | integer | No | Default: `0`. | + +## PanelOptions + +### Properties + +| Property | Type | Required | Description | +|---------------|---------|----------|-------------------------------------------------------------------------------------------------------------| +| `alignValue` | string | No | Controls the value alignment in the TimelineChart component Possible values are: `center`, `left`, `right`. | +| `mergeValues` | boolean | No | Merge equal consecutive values Default: `true`. | +| `rowHeight` | number | No | Controls the row height Default: `0.9`. | +| `showValue` | string | No | TODO docs Possible values are: `auto`, `never`, `always`. | + + diff --git a/docs/sources/developers/kinds/composable/statushistorypanelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/statushistorypanelcfg/schema-reference.md new file mode 100644 index 00000000000..46a8a21a125 --- /dev/null +++ b/docs/sources/developers/kinds/composable/statushistorypanelcfg/schema-reference.md @@ -0,0 +1,40 @@ +--- +keywords: + - grafana + - schema +title: StatusHistoryPanelCfg kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# StatusHistoryPanelCfg kind + +## Maturity: experimental +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|--------------------|-----------------------------|----------|-------------| +| `PanelFieldConfig` | [object](#panelfieldconfig) | **Yes** | | +| `PanelOptions` | [object](#paneloptions) | **Yes** | | + +## PanelFieldConfig + +### Properties + +| Property | Type | Required | Description | +|---------------|---------|----------|----------------| +| `fillOpacity` | integer | No | Default: `70`. | +| `lineWidth` | integer | No | Default: `1`. | + +## PanelOptions + +### Properties + +| Property | Type | Required | Description | +|-------------|--------|----------|-----------------------------------------------------------| +| `colWidth` | number | No | Controls the column width Default: `0.9`. | +| `rowHeight` | number | No | Set the height of the rows Default: `0.9`. | +| `showValue` | string | No | TODO docs Possible values are: `auto`, `never`, `always`. | + + diff --git a/docs/sources/developers/kinds/composable/testdatadataquery/schema-reference.md b/docs/sources/developers/kinds/composable/testdatadataquery/schema-reference.md new file mode 100644 index 00000000000..561d2037195 --- /dev/null +++ b/docs/sources/developers/kinds/composable/testdatadataquery/schema-reference.md @@ -0,0 +1,121 @@ +--- +keywords: + - grafana + - schema +title: TestDataDataQuery kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# TestDataDataQuery kind + +## Maturity: experimental +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|-------------------|-------------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `alias` | string | No | | +| `channel` | string | No | | +| `csvContent` | string | No | | +| `csvFileName` | string | No | | +| `csvWave` | [CSVWave](#csvwave)[] | No | | +| `errorType` | string | No | Possible values are: `server_panic`, `frontend_exception`, `frontend_observable`. | +| `labels` | string | No | | +| `levelColumn` | boolean | No | | +| `lines` | integer | No | | +| `nodes` | [NodesQuery](#nodesquery) | No | | +| `points` | array[] | No | | +| `pulseWave` | [PulseWaveQuery](#pulsewavequery) | No | | +| `rawFrameContent` | string | No | | +| `scenarioId` | string | No | Possible values are: `random_walk`, `slow_query`, `random_walk_with_error`, `random_walk_table`, `exponential_heatmap_bucket_data`, `linear_heatmap_bucket_data`, `no_data_points`, `datapoints_outside_range`, `csv_metric_values`, `predictable_pulse`, `predictable_csv_wave`, `streaming_client`, `simulation`, `usa`, `live`, `grafana_api`, `arrow`, `annotations`, `table_static`, `server_error_500`, `logs`, `node_graph`, `flame_graph`, `raw_frame`, `csv_file`, `csv_content`, `trace`, `manual_entry`, `variables-query`. | +| `seriesCount` | integer | No | | +| `sim` | [SimulationQuery](#simulationquery) | No | | +| `spanCount` | integer | No | | +| `stream` | [StreamingQuery](#streamingquery) | No | | +| `stringInput` | string | No | | +| `usa` | [USAQuery](#usaquery) | No | | + +## CSVWave + +### Properties + +| Property | Type | Required | Description | +|-------------|---------|----------|-------------| +| `labels` | string | No | | +| `name` | string | No | | +| `timeStep` | integer | No | | +| `valuesCSV` | string | No | | + +## NodesQuery + +### Properties + +| Property | Type | Required | Description | +|----------|---------|----------|------------------------------------------------------------| +| `count` | integer | No | | +| `type` | string | No | Possible values are: `random`, `response`, `random edges`. | + +## PulseWaveQuery + +### Properties + +| Property | Type | Required | Description | +|------------|---------|----------|-------------| +| `offCount` | integer | No | | +| `offValue` | number | No | | +| `onCount` | integer | No | | +| `onValue` | number | No | | +| `timeStep` | integer | No | | + +## SimulationQuery + +### Properties + +| Property | Type | Required | Description | +|----------|-------------------|----------|-------------| +| `key` | [object](#key) | **Yes** | | +| `config` | [object](#config) | No | | +| `last` | boolean | No | | +| `stream` | boolean | No | | + +### config + +| Property | Type | Required | Description | +|----------|------|----------|-------------| + +### key + +#### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-------------| +| `tick` | number | **Yes** | | +| `type` | string | **Yes** | | +| `uid` | string | No | | + +## StreamingQuery + +### Properties + +| Property | Type | Required | Description | +|----------|---------|----------|-------------------------------------------------| +| `noise` | integer | **Yes** | | +| `speed` | integer | **Yes** | | +| `spread` | integer | **Yes** | | +| `type` | string | **Yes** | Possible values are: `signal`, `logs`, `fetch`. | +| `bands` | integer | No | | +| `url` | string | No | | + +## USAQuery + +### Properties + +| Property | Type | Required | Description | +|----------|----------|----------|-------------| +| `fields` | string[] | No | | +| `mode` | string | No | | +| `period` | string | No | | +| `states` | string[] | No | | + + diff --git a/docs/sources/developers/kinds/composable/xychartpanelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/xychartpanelcfg/schema-reference.md new file mode 100644 index 00000000000..19e2bfed332 --- /dev/null +++ b/docs/sources/developers/kinds/composable/xychartpanelcfg/schema-reference.md @@ -0,0 +1,132 @@ +--- +keywords: + - grafana + - schema +title: XYChartPanelCfg kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# XYChartPanelCfg kind + +## Maturity: experimental +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|-----------------------|--------------------------------|----------|---------------------------------------------------------| +| `PanelOptions` | [object](#paneloptions) | **Yes** | | +| `ScatterFieldConfig` | [object](#scatterfieldconfig) | **Yes** | | +| `ScatterSeriesConfig` | [object](#scatterseriesconfig) | **Yes** | | +| `ScatterShow` | string | **Yes** | Possible values are: `points`, `lines`, `points+lines`. | +| `SeriesMapping` | string | **Yes** | Possible values are: `auto`, `manual`. | +| `XYDimensionConfig` | [object](#xydimensionconfig) | **Yes** | | + +## PanelOptions + +### Properties + +| Property | Type | Required | Description | +|-----------------|-----------------------------------------------|----------|----------------------------------------| +| `dims` | [XYDimensionConfig](#xydimensionconfig) | No | | +| `seriesMapping` | string | No | Possible values are: `auto`, `manual`. | +| `series` | [ScatterSeriesConfig](#scatterseriesconfig)[] | No | | + +### ScatterSeriesConfig + +#### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-------------| +| `name` | string | No | | +| `x` | string | No | | +| `y` | string | No | | + +### XYDimensionConfig + +#### Properties + +| Property | Type | Required | Description | +|-----------|----------|----------|-------------| +| `frame` | integer | **Yes** | | +| `exclude` | string[] | No | | +| `x` | string | No | | + +## ScatterFieldConfig + +### Properties + +| Property | Type | Required | Description | +|--------------|-----------------------------------------------|----------|-------------------------------------------------------------| +| `labelValue` | [TextDimensionConfig](#textdimensionconfig) | No | | +| `label` | string | No | TODO docs Possible values are: `auto`, `never`, `always`. | +| `lineColor` | [ColorDimensionConfig](#colordimensionconfig) | No | This is actually an empty interface used mainly for naming? | +| `lineStyle` | [LineStyle](#linestyle) | No | TODO docs | +| `lineWidth` | integer | No | | +| `pointColor` | [ColorDimensionConfig](#colordimensionconfig) | No | This is actually an empty interface used mainly for naming? | +| `pointSize` | [ScaleDimensionConfig](#scaledimensionconfig) | No | | +| `show` | string | No | Possible values are: `points`, `lines`, `points+lines`. | + +### ColorDimensionConfig + +This is actually an empty interface used mainly for naming? + +| Property | Type | Required | Description | +|----------|------|----------|-------------| + +### ColorDimensionConfig + +This is actually an empty interface used mainly for naming? + +| Property | Type | Required | Description | +|----------|------|----------|-------------| + +### LineStyle + +TODO docs + +#### Properties + +| Property | Type | Required | Description | +|----------|----------|----------|--------------------------------------------------------| +| `dash` | number[] | No | | +| `fill` | string | No | Possible values are: `solid`, `dash`, `dot`, `square`. | + +### ScaleDimensionConfig + +#### Properties + +| Property | Type | Required | Description | +|----------|---------|----------|-------------| +| `max` | integer | No | | +| `min` | integer | No | | + +### TextDimensionConfig + +#### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|----------------------------------------------------| +| `mode` | string | No | Possible values are: `fixed`, `field`, `template`. | + +## ScatterSeriesConfig + +### Properties + +| Property | Type | Required | Description | +|----------|--------|----------|-------------| +| `name` | string | No | | +| `x` | string | No | | +| `y` | string | No | | + +## XYDimensionConfig + +### Properties + +| Property | Type | Required | Description | +|-----------|----------|----------|-------------| +| `frame` | integer | **Yes** | | +| `exclude` | string[] | No | | +| `x` | string | No | | + + diff --git a/docs/sources/developers/kinds/core/dashboard/schema-reference.md b/docs/sources/developers/kinds/core/dashboard/schema-reference.md index 695d39fcc96..3672213e9d0 100644 --- a/docs/sources/developers/kinds/core/dashboard/schema-reference.md +++ b/docs/sources/developers/kinds/core/dashboard/schema-reference.md @@ -22,19 +22,19 @@ title: Dashboard kind | `style` | string | **Yes** | Theme of dashboard. Possible values are: `dark`, `light`. Default: `dark`. | | `annotations` | [object](#annotations) | No | TODO docs | | `description` | string | No | Description of dashboard. | -| `fiscalYearStartMonth` | integer | No | TODO docs | +| `fiscalYearStartMonth` | integer | No | The month that the fiscal year starts on. 0 = January, 11 = December Default: `0`. | | `gnetId` | string | No | | | `id` | integer | No | Unique numeric identifier for the dashboard.
TODO must isolate or remove identifiers local to a Grafana instance...? | | `links` | [DashboardLink](#dashboardlink)[] | No | TODO docs | | `liveNow` | boolean | No | TODO docs | | `panels` | [object](#panels)[] | No | | -| `refresh` | | No | TODO docs | +| `refresh` | | No | Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". | | `snapshot` | [Snapshot](#snapshot) | No | TODO docs | | `tags` | string[] | No | Tags associated with dashboard. | | `templating` | [object](#templating) | No | TODO docs | | `time` | [object](#time) | No | Time range for dashboard, e.g. last 6 hours, last 7 days, etc | | `timepicker` | [object](#timepicker) | No | TODO docs
TODO this appears to be spread all over in the frontend. Concepts will likely need tidying in tandem with schema changes | -| `timezone` | string | No | Timezone of dashboard, Possible values are: `browser`, `utc`, ``. Default: `browser`. | +| `timezone` | string | No | Timezone of dashboard. Accepts IANA TZDB zone ID or "browser" or "utc". Default: `browser`. | | `title` | string | No | Title of dashboard. | | `uid` | string | No | Unique dashboard identifier that can be generated by anyone. string (8-40) | | `version` | integer | No | Version of the dashboard, incremented each time the dashboard is updated. | diff --git a/docs/sources/developers/kinds/core/preferences/schema-reference.md b/docs/sources/developers/kinds/core/preferences/schema-reference.md new file mode 100644 index 00000000000..fc5bf9ec796 --- /dev/null +++ b/docs/sources/developers/kinds/core/preferences/schema-reference.md @@ -0,0 +1,33 @@ +--- +keywords: + - grafana + - schema +title: Preferences kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# Preferences kind + +## Maturity: merged +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|--------------------|---------------------------------------------------|----------|---------------------------------------------------------------------------------| +| `homeDashboardUID` | string | No | UID for the home dashboard | +| `language` | string | No | Selected language (beta) | +| `queryHistory` | [QueryHistoryPreference](#queryhistorypreference) | No | | +| `theme` | string | No | light, dark, empty is default | +| `timezone` | string | No | The timezone selection
TODO: this should use the timezone defined in common | +| `weekStart` | string | No | day of the week (sunday, monday, etc) | + +## QueryHistoryPreference + +### Properties + +| Property | Type | Required | Description | +|-----------|--------|----------|---------------------------------------------| +| `homeTab` | string | No | one of: '' | 'query' | 'starred'; | + + diff --git a/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md b/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md new file mode 100644 index 00000000000..afc5180618b --- /dev/null +++ b/docs/sources/developers/kinds/core/publicdashboard/schema-reference.md @@ -0,0 +1,25 @@ +--- +keywords: + - grafana + - schema +title: PublicDashboard kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# PublicDashboard kind + +## Maturity: merged +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|------------------------|---------|----------|-----------------------------------------------------------------| +| `annotationsEnabled` | boolean | **Yes** | Flag that indicates if annotations are enabled | +| `dashboardUid` | string | **Yes** | Dashboard unique identifier referenced by this public dashboard | +| `isEnabled` | boolean | **Yes** | Flag that indicates if the public dashboard is enabled | +| `timeSelectionEnabled` | boolean | **Yes** | Flag that indicates if the time range picker is enabled | +| `uid` | string | **Yes** | Unique public dashboard identifier | +| `accessToken` | string | No | Unique public access token | + + diff --git a/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md b/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md new file mode 100644 index 00000000000..7a9b487e6d1 --- /dev/null +++ b/docs/sources/developers/kinds/core/serviceaccount/schema-reference.md @@ -0,0 +1,38 @@ +--- +keywords: + - grafana + - schema +title: ServiceAccount kind +--- +> Both documentation generation and kinds schemas are in active development and subject to change without prior notice. + +# ServiceAccount kind + +## Maturity: merged +## Version: 0.0 + +## Properties + +| Property | Type | Required | Description | +|-----------------|--------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `avatarUrl` | string | **Yes** | AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front
of the service account. | +| `id` | integer | **Yes** | ID is the unique identifier of the service account in the database. | +| `isDisabled` | boolean | **Yes** | IsDisabled indicates if the service account is disabled. | +| `login` | string | **Yes** | Login of the service account. | +| `name` | string | **Yes** | Name of the service account. | +| `orgId` | integer | **Yes** | OrgId is the ID of an organisation the service account belongs to. | +| `role` | string | **Yes** | OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'. Possible values are: `Admin`, `Editor`, `Viewer`. | +| `tokens` | integer | **Yes** | Tokens is the number of active tokens for the service account.
Tokens are used to authenticate the service account against Grafana. | +| `accessControl` | [object](#accesscontrol) | No | AccessControl metadata associated with a given resource. | +| `created` | integer | No | Created indicates when the service account was created. | +| `teams` | string[] | No | Teams is a list of teams the service account belongs to. | +| `updated` | integer | No | Updated indicates when the service account was updated. | + +## accessControl + +AccessControl metadata associated with a given resource. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| + + diff --git a/docs/sources/developers/plugins/publish-a-plugin.md b/docs/sources/developers/plugins/publish-a-plugin.md index 3e3b51f3191..2896896df02 100644 --- a/docs/sources/developers/plugins/publish-a-plugin.md +++ b/docs/sources/developers/plugins/publish-a-plugin.md @@ -105,8 +105,28 @@ To speed up the time it takes to review your plugin: - No. We cannot guarantee specific publishing dates, as plugins are immediately published after a review based on our internal prioritization. +**Can I see metrics of my plugin installs, downloads or usage?** + +- No. We don't offer this information at the moment to plugin authors. + +**How can I update my plugin's catalog page?** + +- The plugin's catalog page content is extracted from the plugin README file. To update the plugin's catalog page, it is necessary to submit an updated plugin with the new content included in the README file. + +**Can I unlist my plugin from the Grafana Plugin's Catalog in case of a bug?** + +- In the event of a bug, unlisting the plugin from the Grafana Plugin's Catalog may be possible in exceptional cases, such as security concerns. However, we do not have control over the specific instances where the plugin is installed. + +**Can I distribute my plugin somewhere else than the Grafana Catalog?** + +- The official method for distributing Grafana plugins is through the Grafana Catalog. Alternative methods, such as installing private or development plugins on local Grafana instances, are available as per the guidelines provided in [this guide](https://grafana.com/docs/grafana/latest/administration/plugin-management/#install-plugin-on-local-grafana). + ## Publishing your plugin for the first time +**Do plugin signatures expire?** + +- Plugin signatures do not currently expire. + {{< figure src="/static/img/docs/plugins/plugins-submission-create2.png" class="docs-image--no-shadow" max-width="650px" >}} 1. [Sign in](https://grafana.com/auth/sign-in) to your Grafana Cloud account. diff --git a/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md b/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md index 6e5af762599..e2724f6818c 100644 --- a/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/expression-queries/index.md @@ -219,3 +219,8 @@ For more information about expressions, refer to [About expressions]({{< relref 1. Write the expression. 1. Click **Apply**. + +## Special cases + +When any queried data source returns no series or numbers, the expression engine returns `NoData`. For example, if a request contains two data source queries that are merged by an expression, if `NoData` is returned by at least one of the data source queries, then the returned result for the entire query is `NoData`. +For more information about how [Grafana Alerting]({{< relref "../../../alerting/" >}}) processes `NoData` results, refer to [No data and error handling]({{< relref "../../../alerting/alerting-rules/create-grafana-managed-rule/#no-data-and-error-handling" >}}). diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index dc936faa815..8e3415c9af5 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -675,6 +675,10 @@ List of allowed headers to be set by the user. Suggested to use for if authentic ## [snapshots] +### enabled + +Set to `false` to disable the snapshot feature (default `true`). + ### external_enabled Set to `false` to disable external snapshot publish endpoint (default `true`). @@ -904,6 +908,36 @@ The following table shows the OAuth provider's setting with the default value an | Google | false | true | User organization roles are set with `defaultRole` and the org role can be changed for Google synced users. | | Google | true | true | User organization roles are set with `defaultRole` for Google. For other providers, the synchronization will be skipped, and the org role can be changed, along with other OAuth provider users' org roles. | +### [auth.github] skip_org_role_sync + +When a user logs in the first time, Grafana sets the organization role based on the value specified in `AutoAssignOrgRole`. If you want to manage organization roles, set the `skip_org_role_sync` option to `true`. GitHub syncs organization roles and sets Grafana Admins. +This also impacts `allow_assign_grafana_admin` setting, by not syncing the grafana admin role from GitHub. + +> **Note:** There is a separate setting called `oauth_skip_org_role_update_sync` which has a different scope. While `skip_org_role_sync` only applies to the specific OAuth provider, `oauth_skip_org_role_update_sync` is a generic setting that affects all configured OAuth providers. + +The following table shows the OAuth provider's setting with the default value and the skip org role sync setting. +| OAuth Provider | `oauth_skip_org_role_sync_update` | `skip_org_role_sync` | Behavior | +| --- | --- | --- | --- | +| GitHub | false | false | User organization roles are set with `defaultRole` and cannot be changed | +| Github | true | false | User organization roles are set with `defaultRole` for GitHub, and Grafana Admins are set. For other providers, the synchronization is skipped, and the org role can be changed, along with other OAuth provider users' org roles. | +| GitHub | false | true | User organization roles are set with `defaultRole`, and the organization role can be changed for GitHub synced users. | +| GitHub | true | true | User organization roles are set with `defaultRole` for Google. For other providers, the synchronization is skipped, and the org role can be changed, along with other OAuth provider users' org roles. | + +### [auth.gitlab] skip_org_role_sync + +When a user logs in the first time, Grafana sets the organization role based on the value specified in `AutoAssignOrgRole`. If you want to manage organization roles, set the `skip_org_role_sync` option to `true`. GitLab syncs organization roles and sets Grafana Admins. +This also impacts `allow_assign_grafana_admin` setting, by not syncing the grafana admin role from GitLab. + +> **Note:** There is a separate setting called `oauth_skip_org_role_update_sync` which has a different scope. While `skip_org_role_sync` only applies to the specific OAuth provider, `oauth_skip_org_role_update_sync` is a generic setting that affects all configured OAuth providers. + +The following table shows the OAuth provider's setting with the default value and the skip org role sync setting. +| OAuth Provider | `oauth_skip_org_role_sync_update` | `skip_org_role_sync` | Behavior | +| --- | --- | --- | --- | +| GitLab | false | false | User organization roles are set with `defaultRole` and cannot be changed | +| Github | true | false | User organization roles are set with `defaultRole` for GitLab, and Grafana Admins are set. For other providers, the synchronization is skipped, and the org role can be changed, along with other OAuth provider users' org roles. | +| GitLab | false | true | User organization roles are set with `defaultRole`, and the organization role can be changed for GitLab synced users. | +| GitLab | true | true | User organization roles are set with `defaultRole` for GitLab. For other providers, the synchronization is skipped, and the org role can be changed, along with other OAuth provider users' org roles. | + ### api_key_max_seconds_to_live Limit of API key seconds to live before expiration. Default is -1 (unlimited). diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 6dccb953693..bcec59423e3 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -30,6 +30,7 @@ Some stable features are enabled by default. You can disable a stable feature by | `internationalization` | Enables internationalization | Yes | | `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | | | `disablePrometheusExemplarSampling` | Disable Prometheus examplar sampling | | +| `logsSampleInExplore` | Enables access to the logs sample feature in Explore | Yes | ## Beta feature toggles @@ -98,6 +99,7 @@ Alpha features might be changed or removed without prior notice. | `alertingBacktesting` | Rule backtesting API for alerting | | `editPanelCSVDragAndDrop` | Enables drag and drop for CSV and Excel files | | `azureMultipleResourcePicker` | Azure multiple resource picker | +| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | ## Development feature toggles @@ -109,6 +111,7 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref | `showFeatureFlagsInUI` | Show feature flags in the settings UI | | `publicDashboardsEmailSharing` | Allows public dashboard sharing to be restricted to only allowed emails | | `k8s` | Explore native k8s integrations | +| `k8sDashboards` | Save dashboards via k8s | | `dashboardsFromStorage` | Load dashboards from the generic storage interface | | `export` | Export grafana instance (to git, etc) | | `azureMonitorResourcePickerForMetrics` | New UI for Azure Monitor Metrics Query | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md index 9146e224623..81703a7c0fe 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md @@ -203,3 +203,16 @@ Your GitHub teams can be referenced in two ways: Example: `@grafana/developers` [Learn more about Team Sync]({{< relref "../../configure-team-sync/" >}}) + +## Skip organization role sync + +To prevent the sync of organization roles from GitHub, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana. +This also impacts the `allow_assign_grafana_admin` setting by not syncing the Grafana admin role from GitHub. + +```ini +[auth.github] +# .. +# prevents the sync of org roles from Github +skip_org_role_sync = true +`` +``` diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 333e190083f..2fe7302ab1d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -223,3 +223,16 @@ the correct teams. Your GitLab groups can be referenced in the same way as `allowed_groups`, like `example` or `foo/bar`. [Learn more about Team Sync]({{< relref "../../configure-team-sync/" >}}) + +## Skip organization role sync + +To prevent the sync of organization roles from GitLab, set `skip_org_role_sync` to `true`. This is useful if you want to manage the organization roles for your users from within Grafana. +This also impacts the `allow_assign_grafana_admin` setting by not syncing the Grafana admin role from GitLab. + +```ini +[auth.gitlab] +# .. +# prevents the sync of org roles from Github +skip_org_role_sync = true +`` +``` diff --git a/docs/sources/shared/tutorials/create-plugin.md b/docs/sources/shared/tutorials/create-plugin.md new file mode 100755 index 00000000000..656bce3cf9e --- /dev/null +++ b/docs/sources/shared/tutorials/create-plugin.md @@ -0,0 +1,40 @@ +--- +title: Create Plugin +--- + +Tooling for modern web development can be tricky to wrap your head around. While you certainly can write your own webpack configuration, for this guide, you'll be using grafana create-plugin tool + +Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin) is a CLI application that simplifies Grafana plugin development, so that you can focus on code. The tool scaffolds a starter plugin and all the required configuration for you. + +1. In the plugin directory, create a plugin from template using create-plugin: + + ``` + npx @grafana/create-plugin + ``` + +1. Change directory to your newly created plugin: + + ``` + cd my-plugin + ``` + +1. Install the dependencies: + + ``` + yarn install + ``` + +1. Build the plugin: + + ``` + yarn dev + ``` + +1. Restart the Grafana server for Grafana to discover your plugin. +1. Open Grafana and go to **Configuration** -> **Plugins**. Make sure that your plugin is there. + +By default, Grafana logs whenever it discovers a plugin: + +``` +INFO[01-01|12:00:00] Registering plugin logger=plugins name=my-plugin +``` diff --git a/docs/sources/shared/tutorials/plugin-anatomy.md b/docs/sources/shared/tutorials/plugin-anatomy.md new file mode 100644 index 00000000000..4d6e2c4ead0 --- /dev/null +++ b/docs/sources/shared/tutorials/plugin-anatomy.md @@ -0,0 +1,29 @@ +--- +title: Plugin Anatomy +--- + +Plugins come in different shapes and sizes. Before we dive deeper, let's look at some of the properties that are shared by all of them. + +Every plugin you create will require at least two files: `plugin.json` and `module.ts`. + +### plugin.json + +When Grafana starts, it scans the plugin directory for any subdirectory that contains a `plugin.json` file. The `plugin.json` file contains information about your plugin, and tells Grafana about what capabilities and dependencies your plugin needs. + +While certain plugin types can have specific configuration options, let's look at the mandatory ones: + +- `type` tells Grafana what type of plugin to expect. Grafana supports three types of plugins: `panel`, `datasource`, and `app`. +- `name` is what users will see in the list of plugins. If you're creating a data source, this is typically the name of the database it connects to, such as Prometheus, PostgreSQL, or Stackdriver. +- `id` uniquely identifies your plugin, and should start with your Grafana username, to avoid clashing with other plugins. [Sign up for a Grafana account](/signup/) to claim your username. + +To see all the available configuration settings for the `plugin.json`, refer to the [plugin.json Schema](/docs/grafana/latest/plugins/developing/plugin.json/). + +### module.ts + +After discovering your plugin, Grafana loads the `module.ts` file, the entrypoint for your plugin. `module.ts` exposes the implementation of your plugin, which depends on the type of plugin you're building. + +Specifically, `module.ts` needs to expose an object that extends [GrafanaPlugin](https://github.com/grafana/grafana/blob/08bf2a54523526a7f59f7c6a8dafaace79ab87db/packages/grafana-data/src/types/plugin.ts#L124), and can be any of the following: + +- [PanelPlugin](https://github.com/grafana/grafana/blob/08bf2a54523526a7f59f7c6a8dafaace79ab87db/packages/grafana-data/src/types/panel.ts#L73) +- [DataSourcePlugin](https://github.com/grafana/grafana/blob/08bf2a54523526a7f59f7c6a8dafaace79ab87db/packages/grafana-data/src/types/datasource.ts#L33) +- [AppPlugin](https://github.com/grafana/grafana/blob/45b7de1910819ad0faa7a8aeac2481e675870ad9/packages/grafana-data/src/types/app.ts#L27) diff --git a/docs/sources/shared/tutorials/publish-your-plugin.md b/docs/sources/shared/tutorials/publish-your-plugin.md new file mode 100644 index 00000000000..097f2417608 --- /dev/null +++ b/docs/sources/shared/tutorials/publish-your-plugin.md @@ -0,0 +1,77 @@ +--- +title: Package your plugin +--- + +Once you're happy with your plugin, it's time to package it, and submit to the plugin repository. + +For users to be able to use the plugin without building it themselves, you need to make a production build of the plugin, and commit to a release branch in your repository. + +To submit a plugin to the plugin repository, you need to create a release of your plugin. While we recommend following the branching strategy outlined below, you're free to use one that makes more sense to you. + +#### Create a plugin release + +Let's create version 0.1.0 of our plugin. + +1. Create a branch called `release-0.1.x`. + + ``` + git checkout -b release-0.1.x + ``` + +1. Do a production build. + + ``` + yarn build + ``` + +1. Add the `dist` directory. + + ``` + git add -f dist + ``` + +1. Create the release commit. + + ``` + git commit -m "Release v0.1.0" + ``` + +1. Create a release tag. + + ``` + git tag -a v0.1.0 -m "Create release tag v0.1.0" + ``` + +1. Push to GitHub. `follow-tags` tells Git to push the release tag along with our release branch. + ``` + git push --set-upstream origin release-0.1.x --follow-tags + ``` + +#### Submit the plugin + +For a plugin to be published on [Grafana Plugins](/grafana/plugins/), it needs to be added to the [grafana-plugin-repository](https://github.com/grafana/grafana-plugin-repository). + +1. Fork the [grafana-plugin-repository](https://github.com/grafana/grafana-plugin-repository) + +1. Add your plugin to the `repo.json` file in the project root directory: + + ```json + { + "id": "", + "type": "", + "url": "https://github.com//my-plugin", + "versions": [ + { + "version": "", + "commit": "", + "url": "https://github.com//my-plugin" + } + ] + } + ``` + +1. [Create a pull request](https://github.com/grafana/grafana-plugin-repository/pull/new/master). + +Once your plugin has been accepted, it'll be published on [Grafana Plugin](/grafana/plugins/), available for anyone to [install](/docs/grafana/latest/plugins/installation/)! + +> We're auditing every plugin that's added to make sure it's ready to be published. This means that it might take some time before your plugin is accepted. We're working on adding more automated tests to improve this process. diff --git a/docs/sources/shared/tutorials/set-up-environment.md b/docs/sources/shared/tutorials/set-up-environment.md new file mode 100644 index 00000000000..07eb372727a --- /dev/null +++ b/docs/sources/shared/tutorials/set-up-environment.md @@ -0,0 +1,34 @@ +--- +title: Set up Environment +--- + +Before you can get started building plugins, you need to set up your environment for plugin development. + +To discover plugins, Grafana scans a _plugin directory_, the location of which depends on your operating system. + +1. Create a directory called `grafana-plugins` in your preferred workspace. + +1. Find the `plugins` property in the Grafana configuration file and set the `plugins` property to the path of your `grafana-plugins` directory. Refer to the [Grafana configuration documentation](/docs/grafana/latest/installation/configuration/#plugins) for more information. + + ```ini + [paths] + plugins = "/path/to/grafana-plugins" + ``` + +1. Restart Grafana if it's already running, to load the new configuration. + +### Alternative method: Docker + +If you don't want to install Grafana on your local machine, you can use [Docker](https://www.docker.com). + +To set up Grafana for plugin development using Docker, run the following command: + +``` +docker run -d -p 3000:3000 -v "$(pwd)"/grafana-plugins:/var/lib/grafana/plugins --name=grafana grafana/grafana:7.0.0 +``` + +Since Grafana only loads plugins on start-up, you need to restart the container whenever you add or remove a plugin. + +``` +docker restart grafana +``` diff --git a/docs/sources/tutorials/_index.md b/docs/sources/tutorials/_index.md new file mode 100644 index 00000000000..cfe6c7633df --- /dev/null +++ b/docs/sources/tutorials/_index.md @@ -0,0 +1,9 @@ +--- +title: 'Tutorials' +menuTitle: 'Tutorials' +description: 'Grafana tutorials' +--- + +# Tutorials + +{{< section >}} diff --git a/docs/sources/tutorials/build-a-data-source-backend-plugin/index.md b/docs/sources/tutorials/build-a-data-source-backend-plugin/index.md new file mode 100644 index 00000000000..ebc6c160926 --- /dev/null +++ b/docs/sources/tutorials/build-a-data-source-backend-plugin/index.md @@ -0,0 +1,180 @@ +--- +title: Build a data source backend plugin +summary: Create a backend for your data source plugin. +description: Create a backend for your data source plugin. +id: build-a-data-source-backend-plugin +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 75 +--- + +## Introduction + +Grafana supports a wide range of data sources, including Prometheus, MySQL, and even Datadog. There's a good chance you can already visualize metrics from the systems you have set up. In some cases, though, you already have an in-house metrics solution that you’d like to add to your Grafana dashboards. This tutorial teaches you to build a support for your data source. + +For more information about backend plugins, refer to the documentation on [Backend plugins](/docs/grafana/latest/developers/plugins/backend/). + +In this tutorial, you'll: + +- Build a backend for your data source +- Implement a health check for your data source +- Enable Grafana Alerting for your data source + +{{% class "prerequisite-section" %}} + +#### Prerequisites + +- Knowledge about how data sources are implemented in the frontend. +- Grafana 7.0 +- Go ([Version](https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/backend/go.mod#L3)) +- [Mage](https://magefile.org/) +- NodeJS ([Version](https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/common/package.json#L66)) +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +To build a backend for your data source plugin, Grafana requires a binary that it can execute when it loads the plugin during start-up. In this guide, we will build a binary using the [Grafana plugin SDK for Go](/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/). + +The easiest way to get started is to use the Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin). Navigate to the plugin folder that you configured in step 1 and type: + +``` +npx @grafana/create-plugin +``` + +Follow the steps and select **datasource** as your plugin type and answer **yes** when prompted to create a backend for your plugin. + +```bash +cd my-plugin +``` + +Install frontend dependencies and build frontend parts of the plugin to _dist_ directory: + +```bash +yarn install +yarn build +``` + +Run the following to update [Grafana plugin SDK for Go](/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/) dependency to the latest minor version: + +```bash +go get -u github.com/grafana/grafana-plugin-sdk-go +go mod tidy +``` + +Build backend plugin binaries for Linux, Windows and Darwin to _dist_ directory: + +```bash +mage -v +``` + +Now, let's verify that the plugin you've built so far can be used in Grafana when creating a new data source: + +1. Restart your Grafana instance. +1. Open Grafana in your web browser. +1. Navigate via the side-menu to **Configuration** -> **Data Sources**. +1. Click **Add data source**. +1. Find your newly created plugin and select it. +1. Enter a name and then click **Save & Test** (ignore any errors reported for now). + +You now have a new data source instance of your plugin that is ready to use in a dashboard: + +1. Navigate via the side-menu to **Create** -> **Dashboard**. +1. Click **Add new panel**. +1. In the query tab, select the data source you just created. +1. A line graph is rendered with one series consisting of two data points. +1. Save the dashboard. + +### Troubleshooting + +#### Grafana doesn't load my plugin + +By default, Grafana requires backend plugins to be signed. To load unsigned backend plugins, you need to +configure Grafana to [allow unsigned plugins](/docs/grafana/latest/plugins/plugin-signature-verification/#allow-unsigned-plugins). +For more information, refer to [Plugin signature verification](/docs/grafana/latest/plugins/plugin-signature-verification/#backend-plugins). + +## Anatomy of a backend plugin + +The folders and files used to build the backend for the data source are: + +| file/folder | description | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Magefile.go` | It’s not a requirement to use mage build files, but we strongly recommend using it so that you can use the build targets provided by the plugin SDK. | +| `/go.mod ` | Go modules dependencies, [reference](https://golang.org/cmd/go/#hdr-The_go_mod_file) | +| `/src/plugin.json` | A JSON file describing the backend plugin | +| `/pkg/main.go` | Starting point of the plugin binary. | + +#### plugin.json + +The [plugin.json](/docs/grafana/latest/developers/plugins/metadata/) file is required for all plugins. When building a backend plugin these properties are important: + +| property | description | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| backend | Should be set to `true` for backend plugins. This tells Grafana that it should start a binary when loading the plugin. | +| executable | This is the name of the executable that Grafana expects to start, see [plugin.json reference](/docs/grafana/latest/developers/plugins/metadata/) for details. | +| alerting | Should be set to `true` if your backend datasource supports alerting. | + +In the next step we will look at the query endpoint! + +## Implement data queries + +We begin by opening the file `/pkg/plugin/plugin.go`. In this file you will see the `SampleDatasource` struct which implements the [backend.QueryDataHandler](https://pkg.go.dev/github.com/grafana/grafana-plugin-sdk-go/backend?tab=doc#QueryDataHandler) interface. The `QueryData` method on this struct is where the data fetching happens for a data source plugin. + +Each request contains multiple queries to reduce traffic between Grafana and plugins. So you need to loop over the slice of queries, process each query, and then return the results of all queries. + +In the tutorial we have extracted a method named `query` to take care of each query model. Since each plugin has their own unique query model, Grafana sends it to the backend plugin as JSON. Therefore the plugin needs to `Unmarshal` the query model into something easier to work with. + +As you can see the sample only returns static numbers. Try to extend the plugin to return other types of data. + +You can read more about how to [build data frames in our docs](/docs/grafana/latest/developers/plugins/data-frames/). + +## Add support for health checks + +Implementing the health check handler allows Grafana to verify that a data source has been configured correctly. + +When editing a data source in Grafana's UI, you can **Save & Test** to verify that it works as expected. + +In this sample data source, there is a 50% chance that the health check will be successful. Make sure to return appropriate error messages to the users, informing them about what is misconfigured in the data source. + +Open `/pkg/plugin/plugin.go`. In this file you'll see that the `SampleDatasource` struct also implements the [backend.CheckHealthHandler](https://pkg.go.dev/github.com/grafana/grafana-plugin-sdk-go/backend?tab=doc#CheckHealthHandler) interface. Navigate to the `CheckHealth` method to see how the health check for this sample plugin is implemented. + +## Enable Grafana Alerting + +1. Open _src/plugin.json_. +1. Add the top level `backend` property with a value of `true` to specify that your plugin supports Grafana Alerting, e.g. + ```json + { + ... + "backend": true, + "executable": "gpx_simple_datasource_backend", + "alerting": true, + "info": { + ... + } + ``` +1. Rebuild frontend parts of the plugin to _dist_ directory: + +```bash +yarn build +``` + +1. Restart your Grafana instance. +1. Open Grafana in your web browser. +1. Open the dashboard you created earlier in the _Create a new plugin_ step. +1. Edit the existing panel. +1. Click on the _Alert_ tab. +1. Click on _Create Alert_ button. +1. Edit condition and specify _IS ABOVE 10_. Change _Evaluate every_ to _10s_ and clear the _For_ field to make the alert rule evaluate quickly. +1. Save the dashboard. +1. After some time the alert rule evaluates and transitions into _Alerting_ state. + +## Summary + +In this tutorial you created a backend for your data source plugin. diff --git a/docs/sources/tutorials/build-a-data-source-plugin/index.md b/docs/sources/tutorials/build-a-data-source-plugin/index.md new file mode 100644 index 00000000000..8cb9af04281 --- /dev/null +++ b/docs/sources/tutorials/build-a-data-source-plugin/index.md @@ -0,0 +1,372 @@ +--- +title: Build a data source plugin +summary: Create a plugin to add support for your own data sources. +description: Create a plugin to add support for your own data sources. +id: build-a-data-source-plugin +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 70 +--- + +## Introduction + +Grafana supports a wide range of data sources, including Prometheus, MySQL, and even Datadog. There's a good chance you can already visualize metrics from the systems you have set up. In some cases, though, you already have an in-house metrics solution that you’d like to add to your Grafana dashboards. This tutorial teaches you to build a support for your data source. + +In this tutorial, you'll: + +- Build a data source to visualize a sine wave +- Construct queries using the query editor +- Configure your data source using the config editor + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana >=7.0 +- NodeJS >=14 +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +{{< docs/shared lookup="tutorials/create-plugin.md" source="grafana" version="latest" >}} + +## Anatomy of a plugin + +{{< docs/shared lookup="tutorials/plugin-anatomy.md" source="grafana" version="latest" >}} + +## Data source plugins + +A data source in Grafana must extend the `DataSourceApi` interface, which requires you to defines two methods: `query` and `testDatasource`. + +### The `query` method + +The `query` method is the heart of any data source plugin. It accepts a query from the user, retrieves the data from an external database, and returns the data in a format that Grafana recognizes. + +``` +async query(options: DataQueryRequest): Promise +``` + +The `options` object contains the queries, or _targets_, that the user made, along with context information, like the current time interval. Use this information to query an external database. + +> The term _target_ originates from Graphite, and the earlier days of Grafana when Graphite was the only supported data source. As Grafana gained support for more data sources, the term "target" became synonymous with any type of query. + +### Test your data source + +`testDatasource` implements a health check for your data source. For example, Grafana calls this method whenever the user clicks the **Save & Test** button, after changing the connection settings. + +``` +async testDatasource() +``` + +## Data frames + +Nowadays there are countless of different databases, each with their own ways of querying data. To be able to support all the different data formats, Grafana consolidates the data into a unified data structure called _data frames_. + +Let's see how to create and return a data frame from the `query` method. In this step, you'll change the code in the starter plugin to return a [sine wave](https://en.wikipedia.org/wiki/Sine_wave). + +1. In the current `query` method, remove the code inside the `map` function. + + The `query` method now look like this: + + ```ts + async query(options: DataQueryRequest): Promise { + const { range } = options; + const from = range!.from.valueOf(); + const to = range!.to.valueOf(); + + const data = options.targets.map(target => { + // Your code goes here. + }); + + return { data }; + } + ``` + +1. In the `map` function, use the `lodash/defaults` package to set default values for query properties that haven't been set: + + ```ts + const query = defaults(target, defaultQuery); + ``` + +1. Create a data frame with a time field and a number field: + + ```ts + const frame = new MutableDataFrame({ + refId: query.refId, + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'value', type: FieldType.number }, + ], + }); + ``` + + `refId` needs to be set to tell Grafana which query that generated this date frame. + +Next, we'll add the actual values to the data frame. Don't worry about the math used to calculate the values. + +1. Create a couple of helper variables: + + ```ts + // duration of the time range, in milliseconds. + const duration = to - from; + + // step determines how close in time (ms) the points will be to each other. + const step = duration / 1000; + ``` + +1. Add the values to the data frame: + + ```ts + for (let t = 0; t < duration; t += step) { + frame.add({ time: from + t, value: Math.sin((2 * Math.PI * t) / duration) }); + } + ``` + + The `frame.add()` accepts an object where the keys corresponds to the name of each field in the data frame. + +1. Return the data frame: + + ```ts + return frame; + ``` + +1. Rebuild the plugin and try it out. + +Your data source is now sending data frames that Grafana can visualize. Next, we'll look at how you can control the frequency of the sine wave by defining a _query_. + +> In this example, we're generating timestamps from the current time range. This means that you'll get the same graph no matter what time range you're using. In practice, you'd instead use the timestamps returned by your database. + +## Define a query + +Most data sources offer a way to query specific data. MySQL and PostgreSQL use SQL, while Prometheus has its own query language, called _PromQL_. No matter what query language your databases are using, Grafana lets you build support for it. + +Add support for custom queries to your data source, by implementing your own _query editor_, a React component that enables users to build their own queries, through a user-friendly graphical interface. + +A query editor can be as simple as a text field where the user edits the raw query text, or it can provide a more user-friendly form with drop-down menus and switches, that later gets converted into the raw query text before it gets sent off to the database. + +### Define the query model + +The first step in designing your query editor is to define its _query model_. The query model defines the user input to your data source. + +We want to be able to control the frequency of the sine wave, so let's add another property. + +1. Add a new number property called `frequency` to the query model: + + **src/types.ts** + + ```ts + export interface MyQuery extends DataQuery { + queryText?: string; + constant: number; + frequency: number; + } + ``` + +1. Set a default value to the new `frequency` property: + + ```ts + export const defaultQuery: Partial = { + constant: 6.5, + frequency: 1.0, + }; + ``` + +### Bind the model to a form + +Now that you've defined the query model you wish to support, the next step is to bind the model to a form. The `FormField` is a text field component from `grafana/ui` that lets you register a listener which will be invoked whenever the form field value changes. + +1. Add a new form field to the query editor to control the new frequency property. + + **QueryEditor.tsx** + + ```ts + const { queryText, constant, frequency } = query; + ``` + + ```ts + + ``` + +1. Add a event listener for the new property. + + ```ts + onFrequencyChange = (event: ChangeEvent) => { + const { onChange, query, onRunQuery } = this.props; + onChange({ ...query, frequency: parseFloat(event.target.value) }); + // executes the query + onRunQuery(); + }; + ``` + + The registered listener, `onFrequencyChange`, calls `onChange` to update the current query with the value from the form field. + + `onRunQuery();` tells Grafana to run the query after each change. For fast queries, this is recommended to provide a more responsive experience. + +### Use the property + +The new query model is now ready to use in our `query` method. + +1. In the `query` method, use the `frequency` property to adjust our equation. + + ```ts + frame.add({ time: from + t, value: Math.sin((2 * Math.PI * query.frequency * t) / duration) }); + ``` + +## Configure your data source + +To access a specific data source, you often need to configure things like hostname, credentials, or authentication method. A _config editor_ lets your users configure your data source plugin to fit their needs. + +The config editor looks similar to the query editor, in that it defines a model and binds it to a form. + +Since we're not actually connecting to an external database in our sine wave example, we don't really need many options. To show you how you can add an option however, we're going to add the _wave resolution_ as an option. + +The resolution controls how close in time the data points are to each other. A higher resolution means more points closer together, at the cost of more data being processed. + +### Define the options model + +1. Add a new number property called `resolution` to the options model. + + **types.ts** + + ```ts + export interface MyDataSourceOptions extends DataSourceJsonData { + path?: string; + resolution?: number; + } + ``` + +### Bind the model to a form + +Just like query editor, the form field in the config editor calls the registered listener whenever the value changes. + +1. Add a new form field to the query editor to control the new resolution option. + + **ConfigEditor.tsx** + + ```ts +
+ +
+ ``` + +1. Add a event listener for the new option. + + ```ts + onResolutionChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + const jsonData = { + ...options.jsonData, + resolution: parseFloat(event.target.value), + }; + onOptionsChange({ ...options, jsonData }); + }; + ``` + + The `onResolutionChange` listener calls `onOptionsChange` to update the current options with the value from the form field. + +### Use the option + +1. Create a property called `resolution` to the `DataSource` class. + + ```ts + export class DataSource extends DataSourceApi { + resolution: number; + + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + + this.resolution = instanceSettings.jsonData.resolution || 1000.0; + } + + // ... + ``` + +1. In the `query` method, use the `resolution` property to calculate the step size. + + **src/DataSource.ts** + + ```ts + const step = duration / this.resolution; + ``` + +## Get data from an external API + +So far, you've generated the data returned by the data source. A more realistic use case would be to fetch data from an external API. + +While you can use something like [axios](https://github.com/axios/axios) or the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to make requests, we recommend using the [`getBackendSrv`](/docs/grafana/latest/packages_api/runtime/getbackendsrv/) function from the [grafana/runtime](/docs/grafana/latest/packages_api/runtime/) package. + +The main advantage of `getBackendSrv` is that it proxies requests through the Grafana server rather making the request from the browser. This is strongly recommended when making authenticated requests to an external API. For more information on authenticating external requests, refer to [Add authentication for data source plugins](/docs/grafana/latest/developers/plugins/add-authentication-for-data-source-plugins/). + +1. Import `getBackendSrv`. + + **src/DataSource.ts** + + ```ts + import { getBackendSrv } from '@grafana/runtime'; + ``` + +1. Create a helper method `doRequest` and use the `datasourceRequest` method to make a request to your API. Replace `https://api.example.com/metrics` to point to your own API endpoint. + + ```ts + async doRequest(query: MyQuery) { + const result = await getBackendSrv().datasourceRequest({ + method: "GET", + url: "https://api.example.com/metrics", + params: query, + }) + + return result; + } + ``` + +1. Make a request for each query. `Promises.all` waits for all requests to finish before returning the data. + + ```ts + async query(options: DataQueryRequest): Promise { + const promises = options.targets.map((query) => + this.doRequest(query).then((response) => { + const frame = new MutableDataFrame({ + refId: query.refId, + fields: [ + { name: "Time", type: FieldType.time }, + { name: "Value", type: FieldType.number }, + ], + }); + + response.data.forEach((point: any) => { + frame.appendRow([point.time, point.value]); + }); + + return frame; + }) + ); + + return Promise.all(promises).then((data) => ({ data })); + } + ``` + +## Summary + +In this tutorial you built a complete data source plugin for Grafana that uses a query editor to control what data to visualize. You've added a data source option, commonly used to set connection options and more. + +### Learn more + +Learn how you can improve your plugin even further, by reading our advanced guides: + +- [Add support for variables](/docs/grafana/latest/developers/plugins/add-support-for-variables/) +- [Add support for annotations](/docs/grafana/latest/developers/plugins/add-support-for-annotations/) +- [Add support for Explore queries](/docs/grafana/latest/developers/plugins/add-support-for-explore-queries/) +- [Build a logs data source](/docs/grafana/latest/developers/plugins/build-a-logs-data-source-plugin/) diff --git a/docs/sources/tutorials/build-a-panel-plugin-with-d3/index.md b/docs/sources/tutorials/build-a-panel-plugin-with-d3/index.md new file mode 100644 index 00000000000..3638df56e06 --- /dev/null +++ b/docs/sources/tutorials/build-a-panel-plugin-with-d3/index.md @@ -0,0 +1,235 @@ +--- +title: Build a panel plugin with D3.js +summary: Learn how to use D3.js in your panel plugins. +description: how to use D3.js in your panel plugins. +id: build-a-panel-plugin-with-d3 +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 60 +--- + +## Introduction + +Panels are the building blocks of Grafana, and allow you to visualize data in different ways. This tutorial gives you a hands-on walkthrough of creating your own panel using [D3.js](https://d3js.org/). + +For more information about panels, refer to the documentation on [Panels](/docs/grafana/latest/features/panels/panels/). + +In this tutorial, you'll: + +- Build a simple panel plugin to visualize a bar chart. +- Learn how to use D3.js to build a panel using data-driven transformations. + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana 7.0 +- NodeJS 12.x +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +{{< docs/shared lookup="tutorials/create-plugin.md" source="grafana" version="latest" >}} + +## Data-driven documents + +[D3.js](https://d3js.org/) is a JavaScript library for manipulating documents based on data. It lets you transform arbitrary data into HTML, and is commonly used for creating visualizations. + +Wait a minute. Manipulating documents based on data? That's sounds an awful lot like React. In fact, much of what you can accomplish with D3 you can already do with React. So before we start looking at D3, let's see how you can create an SVG from data, using only React. + +In **SimplePanel.tsx**, change `SimplePanel` to return an `svg` with a `rect` element. + +```ts +export const SimplePanel: React.FC = ({ options, data, width, height }) => { + const theme = useTheme(); + + return ( + + + + ); +}; +``` + +One single rectangle might not be very exciting, so let's see how you can create rectangles from data. + +1. Create some data that we can visualize. + + ```ts + const values = [4, 8, 15, 16, 23, 42]; + ``` + +1. Calculate the height of each bar based on the height of the panel. + + ```ts + const barHeight = height / values.length; + ``` + +1. Inside a SVG group, `g`, create a `rect` element for every value in the dataset. Each rectangle uses the value as its width. + + ```ts + return ( + + + {values.map((value, i) => ( + + ))} + + + ); + ``` + +1. Rebuild the plugin and reload your browser to see the changes you've made. + +As you can see, React is perfectly capable of dynamically creating HTML elements. In fact, creating elements using React is often faster than creating them using D3. + +So why would you use even use D3? In the next step, we'll see how you can take advantage of D3's data transformations. + +## Transform data using D3.js + +In this step, you'll see how you can transform data using D3 before rendering it using React. + +D3 is already bundled with Grafana, and you can access it by importing the `d3` package. However, we're going to need the type definitions while developing. + +1. Install the D3 type definitions: + + ```bash + yarn add --dev @types/d3 + ``` + +1. Import `d3` in **SimplePanel.tsx**. + + ```ts + import * as d3 from 'd3'; + ``` + +In the previous step, we had to define the width of each bar in pixels. Instead, let's use _scales_ from the D3 library to make the width of each bar depend on the width of the panel. + +Scales are functions that map a range of values to another range of values. In this case, we want to map the values in our datasets to a position within our panel. + +1. Create a scale to map a value between 0 and the maximum value in the dataset, to a value between 0 and the width of the panel. We'll be using this to calculate the width of the bar. + + ```ts + const scale = d3 + .scaleLinear() + .domain([0, d3.max(values) || 0.0]) + .range([0, width]); + ``` + +1. Pass the value to the scale function to calculate the width of the bar in pixels. + + ```ts + return ( + + + {values.map((value, i) => ( + + ))} + + + ); + ``` + +As you can see, even if we're using React to render the actual elements, the D3 library contains useful tools that you can use to transform your data before rendering it. + +## Add an axis + +Another useful tool in the D3 toolbox is the ability to generate _axes_. Adding axes to our chart makes it easier for the user to understand the differences between each bar. + +Let's see how you can use D3 to add a horizontal axis to your bar chart. + +1. Create a D3 axis. Notice that by using the same scale as before, we make sure that the bar width aligns with the ticks on the axis. + + ```ts + const axis = d3.axisBottom(scale); + ``` + +1. Generate the axis. While D3 needs to generate the elements for the axis, we can encapsulate it by generating them within an anonymous function which we pass as a `ref` to a group element `g`. + + ```ts + { + d3.select(node).call(axis as any); + }} + /> + ``` + +By default, the axis renders at the top of the SVG element. We'd like to move it to the bottom, but to do that, we first need to make room for it by decreasing the height of each bar. + +1. Calculate the new bar height based on the padded height. + + ```ts + const padding = 20; + const chartHeight = height - padding; + const barHeight = chartHeight / values.length; + ``` + +1. Translate the axis by adding a transform to the `g` element. + + ```ts + { + d3.select(node).call(axis as any); + }} + /> + ``` + +Congrats! You've created a simple and responsive bar chart. + +## Complete example + +```ts +import React from 'react'; +import { PanelProps } from '@grafana/data'; +import { SimpleOptions } from 'types'; +import { useTheme } from '@grafana/ui'; +import * as d3 from 'd3'; + +interface Props extends PanelProps {} + +export const SimplePanel: React.FC = ({ options, data, width, height }) => { + const theme = useTheme(); + + const values = [4, 8, 15, 16, 23, 42]; + + const scale = d3 + .scaleLinear() + .domain([0, d3.max(values) || 0.0]) + .range([0, width]); + + const axis = d3.axisBottom(scale); + + const padding = 20; + const chartHeight = height - padding; + const barHeight = chartHeight / values.length; + + return ( + + + {values.map((value, i) => ( + + ))} + + { + d3.select(node).call(axis as any); + }} + /> + + ); +}; +``` + +## Summary + +In this tutorial you built a panel plugin with D3.js. diff --git a/docs/sources/tutorials/build-a-panel-plugin/index.md b/docs/sources/tutorials/build-a-panel-plugin/index.md new file mode 100644 index 00000000000..4f5f101d000 --- /dev/null +++ b/docs/sources/tutorials/build-a-panel-plugin/index.md @@ -0,0 +1,259 @@ +--- +title: Build a panel plugin +summary: Learn how to create a custom visualization for your dashboards. +description: Learn how to create a custom visualization for your dashboards. +id: build-a-panel-plugin +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 50 +--- + +## Introduction + +Panels are the building blocks of Grafana. They allow you to visualize data in different ways. While Grafana has several types of panels already built-in, you can also build your own panel, to add support for other visualizations. + +For more information about panels, refer to the documentation on [Panels](/docs/grafana/latest/panels/). + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana >=7.0 +- NodeJS >=14 +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +{{< docs/shared lookup="tutorials/create-plugin.md" source="grafana" version="latest" >}} + +## Anatomy of a plugin + +{{< docs/shared lookup="tutorials/plugin-anatomy.md" source="grafana" version="latest" >}} + +## Panel plugins + +Since Grafana 6.x, panels are [ReactJS components](https://reactjs.org/docs/components-and-props.html). + +Prior to Grafana 6.0, plugins were written in [AngularJS](https://angular.io/). Even though we still support plugins written in AngularJS, we highly recommend that you write new plugins using ReactJS. + +### Panel properties + +The [PanelProps](https://github.com/grafana/grafana/blob/747b546c260f9a448e2cb56319f796d0301f4bb9/packages/grafana-data/src/types/panel.ts#L27-L40) interface exposes runtime information about the panel, such as panel dimensions, and the current time range. + +You can access the panel properties through `props`, as seen in your plugin. + +**src/SimplePanel.tsx** + +```js +const { options, data, width, height } = props; +``` + +### Development workflow + +Next, you'll learn the basic workflow of making a change to your panel, building it, and reloading Grafana to reflect the changes you made. + +First, you need to add your panel to a dashboard: + +1. Open Grafana in your browser. +1. Create a new dashboard, and add a new panel. +1. Select your panel from the list of visualization types. +1. Save the dashboard. + +Now that you can view your panel, try making a change to the panel plugin: + +1. In `SimplePanel.tsx`, change the fill color of the circle. +1. Run `yarn dev` to build the plugin. +1. In the browser, reload Grafana with the new changes. + +## Add panel options + +Sometimes you want to offer the users of your panel an option to configure the behavior of your plugin. By configuring _panel options_ for your plugin, your panel will be able to accept user input. + +In the previous step, you changed the fill color of the circle in the code. Let's change the code so that the plugin user can configure the color from the panel editor. + +#### Add an option + +Panel options are defined in a _panel options object_. `SimpleOptions` is an interface that describes the options object. + +1. In `types.ts`, add a `CircleColor` type to hold the colors the users can choose from: + + ``` + type CircleColor = 'red' | 'green' | 'blue'; + ``` + +1. In the `SimpleOptions` interface, add a new option called `color`: + + ``` + color: CircleColor; + ``` + +Here's the updated options definition: + +**src/types.ts** + +```ts +type SeriesSize = 'sm' | 'md' | 'lg'; +type CircleColor = 'red' | 'green' | 'blue'; + +// interface defining panel options type +export interface SimpleOptions { + text: string; + showSeriesCount: boolean; + seriesCountSize: SeriesSize; + color: CircleColor; +} +``` + +#### Add an option control + +To change the option from the panel editor, you need to bind the `color` option to an _option control_. + +Grafana supports a range of option controls, such as text inputs, switches, and radio groups. + +Let's create a radio control and bind it to the `color` option. + +1. In `src/module.ts`, add the control at the end of the builder: + + ```ts + .addRadio({ + path: 'color', + name: 'Circle color', + defaultValue: 'red', + settings: { + options: [ + { + value: 'red', + label: 'Red', + }, + { + value: 'green', + label: 'Green', + }, + { + value: 'blue', + label: 'Blue', + }, + ], + } + }); + ``` + + The `path` is used to bind the control to an option. You can bind a control to nested option by specifying the full path within a options object, for example `colors.background`. + +Grafana builds an options editor for you and displays it in the panel editor sidebar in the **Display** section. + +#### Use the new option + +You're almost done. You've added a new option and a corresponding control to change the value. But the plugin isn't using the option yet. Let's change that. + +1. To convert option value to the colors used by the current theme, add a `switch` statement right before the `return` statement in `SimplePanel.tsx`. + + **src/SimplePanel.tsx** + + ```ts + let color: string; + switch (options.color) { + case 'red': + color = theme.palette.redBase; + break; + case 'green': + color = theme.palette.greenBase; + break; + case 'blue': + color = theme.palette.blue95; + break; + } + ``` + +1. Configure the circle to use the color. + + ```ts + + + + ``` + +Now, when you change the color in the panel editor, the fill color of the circle changes as well. + +## Create dynamic panels using data frames + +Most panels visualize dynamic data from a Grafana data source. In this step, you'll create one circle per series, each with a radius equal to the last value in the series. + +> To use data from queries in your panel, you need to set up a data source. If you don't have one available, you can use the [TestData DB](/docs/grafana/latest/features/datasources/testdata) data source while developing. + +The results from a data source query within your panel are available in the `data` property inside your panel component. + +```ts +const { data } = props; +``` + +`data.series` contains the series returned from a data source query. Each series is represented as a data structure called _data frame_. A data frame resembles a table, where data is stored by columns, or _fields_, instead of rows. Every value in a field share the same data type, such as string, number, or time. + +Here's an example of a data frame with a time field, `Time`, and a number field, `Value`: + +| Time | Value | +| ------------- | ----- | +| 1589189388597 | 32.4 | +| 1589189406480 | 27.2 | +| 1589189513721 | 15.0 | + +Let's see how you can retrieve data from a data frame and use it in your visualization. + +1. Get the last value of each field of type `number`, by adding the following to `SimplePanel.tsx`, before the `return` statement: + + ```ts + const radii = data.series + .map((series) => series.fields.find((field) => field.type === 'number')) + .map((field) => field?.values.get(field.values.length - 1)); + ``` + + `radii` will contain the last values in each of the series that are returned from a data source query. You'll use these to set the radius for each circle. + +1. Change the `svg` element to the following: + + ```ts + + + {radii.map((radius, index) => { + const step = width / radii.length; + return ; + })} + + + ``` + + Note how we're creating a `` element for each value in `radii`: + + ```ts + { + radii.map((radius, index) => { + const step = width / radii.length; + return ; + }); + } + ``` + + We use the `transform` here to distribute the circle horizontally within the panel. + +1. Rebuild your plugin and try it out by adding multiple queries to the panel. Refresh the dashboard. + +If you want to know more about data frames, check out our introduction to [Data frames](/docs/grafana/latest/developers/plugins/data-frames/). + +## Summary + +In this tutorial you learned how to create a custom visualization for your dashboards. diff --git a/docs/sources/tutorials/build-a-streaming-data-source-plugin/index.md b/docs/sources/tutorials/build-a-streaming-data-source-plugin/index.md new file mode 100644 index 00000000000..7978f6e3629 --- /dev/null +++ b/docs/sources/tutorials/build-a-streaming-data-source-plugin/index.md @@ -0,0 +1,164 @@ +--- +title: Build a streaming data source backend plugin +summary: Create a backend for your data source plugin with streaming capabilities. +description: Create a backend for your data source plugin with streaming capabilities. +id: build-a-streaming-data-source-backend-plugin +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 75 +--- + +## Introduction + +Grafana supports a wide range of data sources, including Prometheus, MySQL, and even Datadog. In previous tutorials we have shown how to extend Grafana capabilities to query custom data sources by [building a backend datasource plugin](/tutorials/build-a-data-source-backend-plugin/). In this tutorial we take a step further and add streaming capabilities to the backend datasource plugin. Streaming allows plugins to push data to Grafana panels as soon as data appears (without periodic polling from UI side). + +For more information about backend plugins, refer to the documentation on [Backend plugins](/docs/grafana/latest/developers/plugins/backend/). + +In this tutorial, you'll: + +- Extend a backend plugin with streaming capabilities + +{{% class "prerequisite-section" %}} + +#### Prerequisites + +- Knowledge about how data sources are implemented in the frontend. +- Knowledge about [backend datasource anatomy](/tutorials/build-a-data-source-backend-plugin/) +- Grafana 8.0+ +- Go ([Version](https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/backend/go.mod#L3)) +- [Mage](https://magefile.org/) +- NodeJS ([Version](https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/common/package.json#L66)) +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +To build a backend for your data source plugin, Grafana requires a binary that it can execute when it loads the plugin during start-up. In this guide, we will build a binary using the [Grafana plugin SDK for Go](/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/). + +The easiest way to get started is to use the Grafana [create-plugin tool](https://www.npmjs.com/package/@grafana/create-plugin). Navigate to the plugin folder that you configured in step 1 and type: + +``` +npx @grafana/create-plugin +``` + +Follow the steps and select **datasource** as your plugin type and answer **yes** when prompted to create a backend for your plugin. + +```bash +cd my-plugin +``` + +Install frontend dependencies and build frontend parts of the plugin to _dist_ directory: + +```bash +yarn install +yarn build +``` + +Run the following to update [Grafana plugin SDK for Go](/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/) dependency to the latest minor version: + +```bash +go get -u github.com/grafana/grafana-plugin-sdk-go +go mod tidy +``` + +Build backend plugin binaries for Linux, Windows and Darwin to _dist_ directory: + +```bash +mage -v +``` + +Now, let's verify that the plugin you've built can be used in Grafana when creating a new data source: + +1. Restart your Grafana instance. +1. Open Grafana in your web browser. +1. Navigate via the side-menu to **Configuration** -> **Data Sources**. +1. Click **Add data source**. +1. Find your newly created plugin and select it. +1. Enter a name and then click **Save & Test** (ignore any errors reported for now). + +You now have a new data source instance of your plugin that is ready to use in a dashboard. To confirm, follow these steps: + +1. Navigate via the side-menu to **Create** -> **Dashboard**. +1. Click **Add new panel**. +1. In the query tab, select the data source you just created. +1. A line graph is rendered with one series consisting of two data points. +1. Save the dashboard. + +### Troubleshooting + +#### Grafana doesn't load my plugin + +By default, Grafana requires backend plugins to be signed. To load unsigned backend plugins, you need to +configure Grafana to [allow unsigned plugins](/docs/grafana/latest/plugins/plugin-signature-verification/#allow-unsigned-plugins). +For more information, refer to [Plugin signature verification](/docs/grafana/latest/plugins/plugin-signature-verification/#backend-plugins). + +## Anatomy of a backend plugin + +As you may notice till this moment we did the same steps described in [build a backend datasource plugin tutorial](/tutorials/build-a-data-source-backend-plugin/). At this point, you should be familiar with backend plugin structure and a way how data querying and health check capabilities could be implemented. Let's take the next step and discuss how datasource plugin can handle data streaming. + +## Add streaming capabilities + +What we want to achieve here is to issue a query to load initial data from a datasource plugin and then switching to data streaming mode where the plugin will push data frames to Grafana time-series panel. + +In short – implementing a streaming plugin means implementing a `backend.StreamHandler` interface which contains `SubscribeStream`, `RunStream`, and `PublishStream` methods. + +`SubscribeStream` is a method where the plugin has a chance to authorize user subscription requests to a channel. Users on the frontend side subscribe to different channels to consume real-time data. + +When returning a `data.Frame` with initial data we can return a special field `Channel` to let the frontend know that we are going to stream data frames after initial data load. When the frontend receives a frame with a `Channel` set it automatically issues a subscription request to that channel. + +Channel is a string identifier of topic to which clients can subscribe in Grafana Live. See a documentation of Grafana Live for [details about channel structure](/docs/grafana/latest/live/live-channel/). + +As said in docs in Grafana Live channel consists of 3 parts delimited by `/`: + +- Scope +- Namespace +- Path + +For datasource plugin channels Grafana uses `ds` scope. Namespace in the case of datasource channels is a datasource unique ID (UID) which is issued by Grafana at the moment of datasource creation. The path is a custom string that plugin authors free to choose themselves (just make sure it consists of allowed symbols). I.e. datasource channel looks like `ds//`. + +So to let the frontend know that we are going to stream data we set a `Channel` field into frame metadata inside `QueryData` implementation. In our tutorial it's a `ds//stream`. The frontend will issue a subscription request to this channel. + +Inside `SubscribeStream` implementation we check whether a user allowed to subscribe on a channel path. If yes – we return an OK status code to tell Grafana user can join a channel: + +```go +status := backend.SubscribeStreamStatusPermissionDenied +if req.Path == "stream" { + // Allow subscribing only on expected path. + status = backend.SubscribeStreamStatusOK +} +return &backend.SubscribeStreamResponse{ + Status: status, +}, nil +``` + +As soon as the first subscriber joins a channel Grafana opens a unidirectional stream to consume streaming frames from a plugin. To handle this and to push data towards clients we implement a `RunStream` method which provides a way to push JSON data into a channel. So we can push data frame like this (error handling skipped): + +```go +// Send frame to stream including both frame schema and data frame parts. +_ = sender.SendFrame(frame, data.IncludeAll) +``` + +Open example datasource query editor and make sure `With Streaming` toggle is on. After doing this you should see data displayed and then periodically updated by streaming frames coming periodically from `RunStream` method. + +The important thing to note is that Grafana opens a unidirectional stream only once per channel upon the first subscriber joined. Every other subscription request will be still authorized by `SubscribeStream` method but the new `RunStream` won't be issued. I.e. you can have many active subscribers but only one running stream. At this moment this guarantee works for a single Grafana instance, we are planning to support this for highly-available Grafana setup (many Grafana instances behind load-balancer) in future releases. + +The stream will be automatically closed as soon as all subscriber users left. + +For the tutorial use case, we only need to properly implement `SubscribeStream` and `RunStream` - we don't need to handle publications to a channel from users. But we still need to write `PublishStream` method to fully implement `backend.StreamHandler` interface. Inside `PublishStream` we just do not allow any publications from users since we are pushing data from a backend: + +```go +return &backend.PublishStreamResponse{ + Status: backend.PublishStreamStatusPermissionDenied, +}, nil +``` + +## Summary + +In this tutorial you created a backend for your data source plugin with streaming capabilities. diff --git a/docs/sources/tutorials/build-an-app-plugin/index.md b/docs/sources/tutorials/build-an-app-plugin/index.md new file mode 100644 index 00000000000..0649b0fd596 --- /dev/null +++ b/docs/sources/tutorials/build-an-app-plugin/index.md @@ -0,0 +1,208 @@ +--- +title: Build an app plugin +summary: Learn at how to create an app for Grafana. +description: Learn at how to create an app for Grafana. +id: build-an-app-plugin +categories: ['plugins'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 50 +draft: true +--- + +## Introduction + +App plugins are Grafana plugins that can bundle data source and panel plugins within one package. They also let you create _custom pages_ within Grafana. Custom pages enable the plugin author to include things like documentation, sign-up forms, or to control other services over HTTP. + +Data source and panel plugins will show up like normal plugins. The app pages will be available in the main menu. + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana 7.0 +- NodeJS 12.x +- yarn + {{% /class %}} + +## Set up your environment + +{{< docs/shared lookup="tutorials/set-up-environment.md" source="grafana" version="latest" >}} + +## Create a new plugin + +{{< docs/shared lookup="tutorials/create-plugin.md" source="grafana" version="latest" >}} + +## Anatomy of a plugin + +{{< docs/shared lookup="tutorials/plugin-anatomy.md" source="grafana" version="latest" >}} + +## App plugins + +App plugins let you bundle resources such as dashboards, panels, and data sources into a single plugin. + +Any resource you want to include needs to be added to the `includes` property in the `plugin.json` file. To add a resource to your app plugin, you need to include it to the `plugin.json`. + +Plugins that are included in an app plugin are available like any other plugin. + +Dashboards and pages can be added to the app menu by setting `addToNav` to `true`. + +By setting `"defaultNav": true`, users can navigate to the dashboard by clicking the app icon in the side menu. + +## Add a custom page + +App plugins let you extend the Grafana user interface through the use of _custom pages_. + +Any requests sent to `/a/`, e.g. `/a/myorgid-simple-app/`, are routed to the _root page_ of the app plugin. The root page is a React component that returns the content for a given route. + +While you're free to implement your own routing, in this tutorial you'll use a tab-based navigation page that you can use by calling `onNavChange`. + +Let's add a tab for managing server instances. + +1. In the `src/pages` directory, add a new file called `Instances.tsx`. This component contains the content for the new tab. + + ```ts + import { AppRootProps } from '@grafana/data'; + import React, { FC } from 'react'; + + export const Instances: FC = ({ query, path, meta }) => { + return

Hello

; + }; + ``` + +1. Register the page by adding it to the `pages` array in `src/pages/index.ts`. + + **index.ts** + + ```ts + import { Instances } from './Instances'; + ``` + + ```ts + { + component: Instances, + icon: 'file-alt', + id: 'instances', + text: 'Instances', + } + ``` + +1. Add the page to the app menu, by including it in `plugin.json`. This will be the main view of the app, so we'll set `defaultNav` to let users quickly get to it by clicking the app icon in the side menu. + + **plugin.json** + + ```json + "includes": [ + { + "type": "page", + "name": "Instances", + "path": "/a/myorgid-simple-app?tab=instances", + "role": "Viewer", + "addToNav": true, + "defaultNav": true + } + ] + ``` + +> **Note:** While `page` includes typically reference pages created by the app, you can set `path` to any URL, internal or external. Try setting `path` to `https://grafana.com`. + +## Configure the app + +Let's add a new configuration page where users are able to configure default zone and regions for any instances they create. + +1. In `module.ts`, add new configuration page using the `addConfigPage` method. `body` is the React component that renders the page content. + + **module.ts** + + ```ts + .addConfigPage({ + title: 'Defaults', + icon: 'fa fa-info', + body: DefaultsConfigPage, + id: 'defaults', + }) + ``` + +## Add a dashboard + +#### Include a dashboard in your app + +1. In `src/`, create a new directory called `dashboards`. +1. Create a file called `overview.json` in the `dashboards` directory. +1. Copy the JSON definition for the dashboard you want to include and paste it into `overview.json`. If you don't have one available, you can find a sample dashboard at the end of this step. +1. In `plugin.json`, add the following object to the `includes` property. + + - The `name` of the dashboard needs to be the same as the `title` in the dashboard JSON model. + - `path` points out the file that contains the dashboard definition, relative to the `plugin.json` file. + + ```json + "includes": [ + { + "type": "dashboard", + "name": "System overview", + "path": "dashboards/overview.json", + "addToNav": true + } + ] + ``` + +1. Save and restart Grafana to load the new changes. + +## Bundle a plugin + +An app plugin can contain panel and data source plugins that get installed along with the app plugin. + +In this step, you'll add a data source to your app plugin. You can add panel plugins the same way by changing `datasource` to `panel`. + +1. In `src/`, create a new directory called `datasources`. +1. Create a new data source using Grafana create-plugin tool in a temporary directory. + + ```bash + mkdir tmp + cd tmp + npx @grafana/create-plugin + ``` + +1. Move the `src` directory in the data source plugin to `src/datasources`, and rename it to `my-datasource`. + + ```bash + mv ./my-datasource/src ../src/datasources/my-datasource + ``` + +Any bundled plugins are built along with the app plugin. Grafana looks for any subdirectory containing a `plugin.json` file and attempts to load a plugin in that directory. + +To let users know that your plugin bundles other plugins, you can optionally display it on the plugin configuration page. This is not done automatically, so you need to add it to the `plugin.json`. + +1. Include the data source in the `plugin.json`. The `name` property is only used for displaying in the Grafana UI. + + ```json + "includes": [ + { + "type": "datasource", + "name": "My data source" + } + ] + ``` + +#### Include external plugins + +If you want to let users know that your app requires an existing plugin, you can add it as a dependency in `plugin.json`. Note that they'll still need to install it themselves. + +```json +"dependencies": { + "plugins": [ + { + "type": "panel", + "name": "Worldmap Panel", + "id": "grafana-worldmap-panel", + "version": "^0.3.2" + } + ] +} +``` + +## Summary + +In this tutorial you learned how to create an app plugin. diff --git a/docs/sources/tutorials/create-alerts-from-flux-queries/index.md b/docs/sources/tutorials/create-alerts-from-flux-queries/index.md new file mode 100644 index 00000000000..405b4f861cd --- /dev/null +++ b/docs/sources/tutorials/create-alerts-from-flux-queries/index.md @@ -0,0 +1,331 @@ +--- +title: How to create Grafana alerts with InfluxDB and the Flux query language +summary: Create complex alerts from Flux queries in the new Grafana Alerting +description: Create complex alerts from Flux queries in the new Grafana Alerting +id: grafana-alerts-flux-queries +categories: ['alerting'] +tags: ['advanced'] +status: published +authors: ['grant_pinkos'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 70 +--- + +# How to create Grafana alerts with InfluxDB and the Flux query language + +[Grafana Alerting](/docs/grafana/latest/alerting/) represents a powerful new approach to systems observability and incident response management. While the alerting platform is perhaps best known for its strong integrations with Prometheus, the system works with numerous popular data sources including InfluxDB. In this tutorial we will learn how to create Grafana alerts using InfluxDB and the newer Flux query language. We will cover five common scenarios from the most basic to the most complex. Together, these five scenarios will provide an excellent guide for almost any type of alerting query that you wish to create using Grafana and Flux. + +Before we dive into our alerting scenarios, it is worth considering the development of InfluxDB's two popular query languages: InfluxQL and Flux. Originally, InfluxDB used [InfluxQL](https://docs.influxdata.com/influxdb/v2.5/reference/syntax/influxql/spec/) as their query language, which uses a SQL-like syntax. But beginning with InfluxDB v1.8, the company introduced [Flux](https://docs.influxdata.com/flux/v0.x/), "an open source functional data scripting language designed for querying, analyzing, and acting on data." "Flux," its official documentation goes on to state, "unifies code for querying, processing, writing, and acting on data into a single syntax. The language is designed to be usable, readable, flexible, composable, testable, contributable, and shareable." + +In the following five examples we will see just how powerful and flexible the new Flux query language can be. We will also see just how well Flux pairs with Grafana Alerting. + +## Example 1: Create an alert when a value is above or below a set threshold + +Our first example uses a common real-world scenario for InfluxDB and Grafana Alerting. Popular with IoT and edge applications, InfluxDB excels at on-site, real-time observability. In this example, and in fact for many of the following examples, we will consider the hypothetical scenario where we are monitoring a number of fluid tanks in a manufacturing plant. This scenario, [based on an actual application of InfluxDB and Alerting](/go/grafanaconline/2021/plant-efficiency-grafana-cloud/), will allow us to work through Grafana's various alerting setups, progressing from the simplest to the most complex. + +For Example 1, let's consider the following scenario: we are monitoring one tank, `A5`, for which we are storing real-time temperature data. We need to make sure that the temperature in this tank is always greater than 30 °C and less than 60 °C. + +We want to write a Grafana alert that will trigger whenever the temperature in tank `A5` crosses the lower threshold of 30 °C or the upper threshold of 60 °C. + +To do this, we'll: 1. create a Grafana alert rule. 1. add a Flux query. 1. add expressions to the alert rule. + +### Create a Grafana Alert rule + +1. Open the Grafana alerting menu and select **Alert rules**. +1. Click **New alert rule**. +1. Give your alert rule a name and then select **Grafana managed alert**. + For InfluxDB, you will always create a [Grafana managed rule](/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/#add-grafana-managed-rule). + +### Add an initial Flux query to the alert rule + +Still in the **Step 2** section of the Alert rule page, you will see three boxes: a query editor (`A`), and then two sections labelled `B` and `C`. You will use these three sections to construct your rule. Let's move through them one by one. + +First, we want to query the data in our imaginary InfluxDB instance to obtain a time series graph of the temperature of tank A5. For this you would choose your InfluxDB data source from the dropdown and then write a query like this: + + ``` + from(bucket: "RetroEncabulator") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "TemperatureData") + |> filter(fn: (r) => r["Tank"] == "A5") + |> filter(fn: (r) => r["_field"] == "Temperature") + |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) + |> yield(name: "mean") + ``` + +This is a fairly typical Flux query. Let's go through it function by function. We begin using [the `from()` function](https://docs.influxdata.com/flux/v0.x/stdlib/influxdata/influxdb/from/) to choose the correct bucket where our tank data resides. Then we use [a `range()` function](https://docs.influxdata.com/flux/v0.x/stdlib/universe/range/) to filter our rows based on time constraints. Then we pass our data through three [`filter()` functions](https://docs.influxdata.com/flux/v0.x/stdlib/universe/filter/) to narrow our results. We choose a specific [`measurement` (a special keyword in InfluxDB)](https://docs.influxdata.com/influxdb/v1.8/concepts/glossary/#measurement), then our tank in question (`A5`), and then a specific [`field` (another special keyword in InfluxDB)](https://docs.influxdata.com/influxdb/v1.8/concepts/glossary/#field). After this we pass the data into [an `aggregateWindow()` function](https://docs.influxdata.com/flux/v0.x/stdlib/universe/aggregatewindow/), which downsamples our data into specific periods of time, and then finally [a `yield()` function](https://docs.influxdata.com/flux/v0.x/stdlib/universe/yield/), which specifies which final result we want: `mean`. + +This Flux query will yield a time-series graph like this: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-timeseries-graph.png) + +### Add expressions to your Grafana Alert rule + +With data now appearing in our rule setup, our next step is to create an [expression](/docs/grafana/v9.0/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions/#using-expressions). Move to section `B`. For this scenario, we want to create a Reduce expression that will reduce the above to a single value. In this image, you can see that we have chosen to reduce our time-series data the `Last` value from input `A`. In this case, it returns a value 53 degrees celsius for Tank A5: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-reduce-expression.png) + +Finally, we need to create a math expression that Grafana will alert on. In our case we will write an expression with two conditions separated by the OR `||` operator. We want to trigger an alert any time our result in section `B` is less than 30 or more than 60. This looks like `$B < 30 || $B > 60`: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-math-expression.png) + +Set the alert condition to `C - expression`. We can now preview our alert. Here is a preview of this alert when the state is `Normal`: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-alert-preview-state-normal.png) + +And here is a preview of this alert when the state is `Alerting`: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-alert-alert-preview-state-alerting.png) + +Note that the Reduce expression above is needed. Without it, when previewing the results, Grafana would display `invalid format of evaluation results for the alert definition B: looks like time series data, only reduced data can be alerted on`. + +💡Tip: In case your locale is still stubbornly using Fahrenheit, we can modify the above Flux query by adding (before the aggregateWindow statement) a map() function to to convert (or map) the values from °C to °F. Note that we are not creating a new field. We are simply remapping the existing value. + +```flux +|> map(fn: (r) => ({r with _value: r._value * 1.8 + 32.0})) +``` + +### Conclusion + +Using these three steps you can create a Flux-based Grafana Alert that will trigger on either of two thresholds from a single data source. But what if you need to trigger an alert based on **multiple conditions and from multiple time-series**? In example two we will cover this very scenario. + +## Example 2: how to create a Grafana alert from two queries and two conditions + +Let's mix things up a bit for example two and leave our imaginary manufacturing plant. Imagine you're an assistant to the great Dr. Emmett Brown from Back to the Future, and Doc has tasked you with the following challenge: "I want an alert sent to me every time both conditions for time travel are met: when the velocity of a vehicle reaches 88 miles per hour and an object generates 1.21 jigowatts of electricity." + +Let's assume we are tracking this data in InfluxDB and Grafana. Let's also assume that each of the above data sources comes from different buckets. How do we alert on this? How do we use Grafana and Flux to alert on two distinct conditions originating from two distinct data sources? + +### Add two Flux queries to your Grafana Alert rule + +Like we did in example 1, let's first mock up our queries. Our query for our vehicle data is very similar to our last query. We use a `from()`, `range()`, and a sequence of `filter()` functions. We then use `AggregateWindow()` and `yield()` to narrow our data even more. In this case, the result is a time series tracking the velocity of our 1983 DeLorean: + +```flux +from(bucket: "vehicles") +|> range(start: v.timeRangeStart, stop: v.timeRangeStop) +|> filter(fn: (r) => r["_measurement"] == "VehicleData") +|> filter(fn: (r) => r["VehicleType"] == "DeLorean") +|> filter(fn: (r) => r["VehicleYear"] == "1983") +|> filter(fn: (r) => r["_field"] == "velocity") +|> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) +|> yield(name: "mean") +``` + +Our second query will trigger an alert whenever our electricity resource (the lightning strike on the Hill Valley clocktower) reaches the needed 1.21 jigowatts. A query like this would look very similar to our vehicle velocity query: + +```flux +from(bucket: "HillValley") +|> range(start: v.timeRangeStart, stop: v.timeRangeStop) +|> filter(fn: (r) => r["_measurement"] == "ElectricityData") +|> filter(fn: (r) => r["Location"] == "clocktower") +|> filter(fn: (r) => r["Source"] == "lightning") +|> filter(fn: (r) => r["_field"] == "power") +|> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) +|> yield(name: "mean") +``` + +We are now ready to modify this data using expressions. + +### Add expressions to your Grafana Alert rule + +1. Let's now use the same steps to reduce each query to the last (most recent) value. Reducing Query `A` to a single value might look like this: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-additional-queries-reduce-expression-A.png) + +1. And here we are reducing query `B`: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-additional-queries-reduce-expression-B.png) + +1. Now, in section `C` we need to create a math expression to be alerted on. In this case we will use the AND `&&` operator to specify that two conditions must be met: the value of `C` (the reduced value from query `A`) must be greater than 88.0 while the value of `D` (the reduced value from query `B`) must be greater than 1.21. We write this as `$C > 88.0 && $D > 1.21` + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-additional-queries-math-expression.png) + +And here is a preview of our alerts: + +![grafana alerts from flux queries](https://raw.githubusercontent.com/grafana/tutorials/master/content/tutorials/assets/flux-additional-queries-alert-preview.png) + +💡Tip: If your data in InfluxDB happens to have an unnecessarily large number of digits to the right of the decimal (such as 1.2104705741732575 shown above), and you want your Grafana alerts to be more legible, try using {{ printf "%.2f" $values.D.Value }}. For example, in the annotation Summary, we could write the following: + +``` +{{ $values.D.Labels.Source }} at the {{ $values.D.Labels.Location }} has generated {{ printf "%.2f" $values.D.Value }} jigowatts.` +``` + +This will display as follows: +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-tip-significant-figures.png)) + +You can reference our documentation on [alert message templating](/docs/grafana/latest/alerting/contact-points/message-templating/) to learn more about this powerful feature. + +### Conclusion + +In this example we showed how to create a Flux-based alert that uses two distinct conditions from two distinct queries that use data from two distinct data sources. For example three we will switch gears and tackle another popular alerting scenario: how to create an alert based on an aggregated (per day) value. + +## Example 3: how to create a Grafana Alert based on an aggregated (per-day) value + +One of the most common requests in [Grafana's community forum](https://community.grafana.com) involves graphing daily electrical consumption and production. This sort of data is very often stored in InfluxDB. In this example we will see how to aggregate time series data into a per-day value and then alert on it. + +Let’s assume our electricity meter sends a reading to InfluxDB once per hour and contains the total kWh used for that hour. We want to write a query that will aggregate these per-hour values into a per-day value, then create an alert that triggers when the power consumption (kWh) exceeds 5,000 kWh per day. + +### Add an initial Flux query to your Grafana Alert rule + +1. Let's begin by examining a typical query and the resulting time graph for our hourly data across a 7-day period. A query like this is shown below: + + ```flux + from(bucket: "RetroEncabulator") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "ElectricityData") + |> filter(fn: (r) => r["Location"] == "PlantD5") + |> filter(fn: (r) => r["_field"] == "power_consumed") + |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) + |> yield(name: "power") + ``` + + We can see the same pattern of Flux functions here that we say in examples 1 and 2. A query like this would produce a graph similar to the following: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-aggregatewindow-timeseries-graph.png) + +1. Now let's adjust our query to calculate daily usage. With many datasources, this can be a rather complex operation. But with Flux, by simply changing the aggregateWindow function parameters we can calculate the daily usage over the same 7-day period: + + ```flux + from(bucket: "RetroEncabulator") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "ElectricityData") + |> filter(fn: (r) => r["Location"] == "PlantD5") + |> filter(fn: (r) => r["_field"] == "power_consumed") + |> aggregateWindow(every: 1d, fn: sum) + |> yield(name: "power") + ``` + + Note how we've adjusted our `aggregateWindow()` function to `aggregateWindow(every: 1d, fn: sum)`. This results in a graph like so: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-aggregatewindow-aggregated.png) + +1. Add expressions to your Grafana Alert rule. + + Now that we have our per-day query correct, we can continue using the same pattern as before, adding expressions to reduce and perform math on our results. + + As before, let's reduce our query to a single value: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-aggregatewindow-reduce-expression.png) + + Now create a math expression to be alerted on and set the evaluation behavior. In this case we want to write `$B > 5000`: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-aggregatewindow-math-expression.png) + + And now we are alerting on our daily electricity consumption whenever we exceed 5000 kWh. Here is preview of our alert: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-aggregatewindow-alert-preview.png) + +### Conclusion + +Plotting and aggregating electrical consumption is a common use case for combining InfluxDB and Grafana. Using Flux, we saw just how easy it can be to group our data by day and then alert on that daily value. In our next two examples we will examine the more complex form of Grafana Alert: multidimensional alerts. + +## Example 4: create a dynamic (multidimensional) Grafana Alert using Flux + +Let’s return to our fluid tanks from example 1, but this time let’s assume we have 5 tanks (A5, B4, C3, D2, and E1). We are now tracking the temperature in five tanks: A5, B4, C3, D2, and E1. + +We want to create one multidimensional alert that will notify us whenever the temperature in any tank is less than 30 °C or greater than 60 °C. + +### Add an initial Flux query to your Grafana Alert rule + +We begin, as always, by writing our initial query. This is very similar to our query in example 1, but note how our third `filter()` function captures the data from all five tanks and not just `A5`: + +```flux +from(bucket: "HyperEncabulator") +|> range(start: v.timeRangeStart, stop: v.timeRangeStop) +|> filter(fn: (r) => r["_measurement"] == "TemperatureData") +|> filter(fn: (r) => r["MeasType"] == "actual") +|> filter(fn: (r) => r["Tank"] == "A5" or r["Tank"] == "B4" or r["Tank"] == "C3" or r["Tank"] == "D2" or r["Tank"] == "E1") +|> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) +|> yield(name: "mean") +``` + +💡Tip: If the tanks were shut down every night from 23:00 to 07:00, they would possibly fall below the 30 °C threshold. If one did not want to receive alerts during those hours, one can use the Flux function hourSelection() which filters rows by time values in a specified hour range. + +```flux +|> hourSelection(start: 7, stop: 23)` +``` + +A query like the one above will produce a time series graph like this: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-multidimensional-timeseries-graph.png) + +### Add expressions to your Grafana Alert rule + +1. We create a Reduce expression that will reduce the time series for each tank to a single value. This gives us five distinct temperatures: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-multidimensional-reduce-expression.png)) + +1. Create a math expression to be alerted on. This is the exact same expression from example 1, `$B < 30 || $B > 60`: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-multidimensional-math-expression.png) + +As we can see three tanks are within the acceptable thresholds while two tanks have crossed the upper boundary. This would trigger an alert for tanks `D2` and `E1`. + +### Conclusion + +With multidimensional alerts we can avoid repeating ourselves. But what if the scenario were even more complex? In the next and final example, we will examine how to use multidimensional alerts to create the most dynamic alerts possible. + +## Example 5: how to create a dynamic (multidimensional) Grafana Alert using multiple queries and multiple thresholds with Flux + +For this final example let's continue with our five fluid tanks and their five datasets.Let’s assume again that each tank has a temperature controller with a setpoint value that is stored in InfluxDB. Let’s mix things up and assume that each tank has a _different_ setpoint, where we always need to be within 3 degrees of the setpoint. + +We want to create one multidimensional alert that will cover each unique scenario for each tank, triggering an alert whenever any tank's temperature moves beyond its unique allowable range. + +To better visualize this challenge, here is a table representing our five tanks, their temperature setpoints, and their allowable range: + +| Tank | Setpoint | Allowable Range (±3) | +| ---- | -------- | -------------------- | +| A5 | 45 | 42 to 48 | +| B4 | 55 | 52 to 58 | +| C3 | 60 | 57 to 63 | +| D2 | 72 | 69 to 75 | +| E1 | 80 | 77 to 83 | + +With Grafana Alerting, we can create a single multidimensional rule to cover all 5 tanks, and we can use Flux to compare the setpoint and actual value for each tank. In other words, one multidimensional alert can monitor 5 separate tanks, each with different setpoints and actual values, but all with one common "allowable threshold" (i.e. a temperature difference of ±3 degrees). + +### Add an initial Flux query to your Grafana Alert rule + +Let's begin with our data query. It is similar to our past queries, only now more complex. We must add extra functions to get our data into the proper format, including a `pivot()`, `map()`, `rename()`, `keep()`, and `drop()` function: + +```flux +from(bucket: "HyperEncabulator") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "TemperatureData") + |> filter(fn: (r) => r["MeasType"] == "actual" or r["MeasType"] == "setpoint") + |> filter(fn: (r) => r["Tank"] == "A5" or r["Tank"] == "B4" or r["Tank"] == "C3" or r["Tank"] == "D2" or r["Tank"] == "E1") + |> filter(fn: (r) => r["_field"] == "Temperature") + |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) + |> pivot(rowKey:["_time"], columnKey: ["MeasType"], valueColumn: "_value") + |> map(fn: (r) => ({ r with _value: (r.setpoint - r.actual)})) + |> rename(columns: {_value: "difference"}) + |> keep(columns: ["_time", "difference", "Tank"]) + |> drop(columns: ["actual", "setpoint"]) + |> yield(name: "mean") +``` + +Note in the above that we are calculating the difference between the actual and the setpoint. The way Grafana parses the result from InfluxDB is that if a \_value column is found, it is assumed to be a time-series. The quick workaround is to add the following `rename()` function: + +```flux + |> rename(columns: {_value: "something"}) +``` + +The above query results in this time series: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-complex-query-timeseries-graph.png) + +### Add expressions to your Grafana Alert rule + +1. Again, we create a Reduce expression for the above query to reduce each of the above to a single value. This value represents the temperature differential between each tank's setpoint and its actual real-time temperature: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-complex-query-reduce-expression.png) + +1. Now we create a math expression to be alerted on. This time we will create a condition that checks if the absolute value of our reduce calculation is greater than 3, `abs($(B))>3.0`: + + ![grafana alerts from flux queries](/media/tutorials/screenshot-flux-complex-query-math-expression.png) + +We can now see that two tanks, `D2` and `E1`, are evaluating to true. When we preview the alert we can see that those two tanks will trigger a notification and change their state from `Normal` to `Alerting`: + +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-complex-query-alert-preview-state-normal.png) +![grafana alerts from flux queries](/media/tutorials/screenshot-flux-complex-query-alert-preview-state-alerting.png) + +### Conclusion + +Flux queries and Grafana Unified Alerting are a powerful combination to identify practically any alertable conditions in your dataset, or across your entire system. For more information on Grafana Alerting, [visit the documentation here](/docs/grafana/latest/alerting/). For more information on the Flux query language, [you can visit that documentation as well](https://docs.influxdata.com/flux/v0.x/). diff --git a/docs/sources/tutorials/create-users-and-teams/index.md b/docs/sources/tutorials/create-users-and-teams/index.md new file mode 100644 index 00000000000..23b02d1d663 --- /dev/null +++ b/docs/sources/tutorials/create-users-and-teams/index.md @@ -0,0 +1,236 @@ +--- +title: Create users and teams +summary: Learn how to set up teams and users. +description: Learn how to set up teams and users. +id: create-users-and-teams +categories: ['administration'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 20 +--- + +## Introduction + +This tutorial is for admins or anyone that wants to learn how to manage +users in Grafana. You'll add multiple local users, organize them into teams, +and make sure they're only able to access the resources they need. + +### Scenario + +_Graphona_, a fictional telemarketing company, has asked you to configure Grafana +for their teams. + +In this scenario, you'll: + +- Create users and organize them into teams. +- Manage resource access for each user and team through roles and folders. + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana 7.0 or newer, this tutorial was tested with Grafana 8.5. +- A user with the Admin or Server Admin role. + {{% /class %}} + +## Add users + +In Grafana, all users are granted an _organization role_ that determines what +resources they can access. + +There are three types of organization roles in Grafana. The **Grafana Admin** is +a global role, the default `admin` user has this role. + +- **Grafana Admin -** Manage organizations, users, and view server-wide settings. +- **Organization Administrator -** Manage data sources, teams, and users within an organization. +- **Editor -** Create and edit dashboards. +- **Viewer -** View dashboards. + +> **Note**: You can also configure Grafana to allow [anonymous access](/docs/grafana/latest/auth/overview/#anonymous-authentication), to make dashboards available even to those who don't have a Grafana user account. That's how Grafana Labs made https://play.grafana.org publicly available. + +### Exercise + +Graphona has asked you to add a group of early adopters that work in the Marketing and Engineering teams. They'll need to be able to edit their own team's dashboards, but want to have view access to dashboards that belong to the other team. + +| Name | Email | Username | +| ----------------- | ----------------------------- | ----------------- | +| Almaz Russom | almaz.russom@example.com | almaz.russom | +| Brenda Tilman | brenda.tilman@example.com | brenda.tilman | +| Mada Rawdha Tahan | mada.rawdha.tahan@example.com | mada.rawdha.tahan | +| Yuan Yang | yuan.yang@example.com | yuan.yang | + +#### Add users + +Repeat the following steps for each of the employees in the table above to create the new user accounts: + +1. Log in as a user that has the **Server Admin** role. +1. On the sidebar, click the **Server Admin** (shield) icon. +1. Choose **Users** from the menu drop-down, then click **New User**. +1. Enter the **Name**, **Email**, **Username**, and **Password** from the table above. +1. Click the **Create User** button to create the account. + +When you create a user they are granted the Viewer role by default, which means that they won't be able to make any changes to any of the resources in Grafana. That's ok for now, you'll grant more user permissions by adding users to _teams_ in the next step. + +## Assign users to teams + +Teams let you grant permissions to a group of users, instead of granting permissions to individual users one at a time. + +Teams are useful when onboarding new colleagues. When you add a user to a team, they get access to all resources assigned to that team. + +### Exercise + +In this step, you'll create two teams and assign users to them. + +| Username | Team | +| ----------------- | ----------- | +| brenda.tilman | Marketing | +| mada.rawdha.tahan | Marketing | +| almaz.russom | Engineering | +| yuan.yang | Engineering | + +#### Create a team + +Create the _Marketing_ and _Engineering_ teams. + +1. In the sidebar, hover your mouse over the **Configuration** (gear) icon and + then click **Teams**. +1. Click **New team**. +1. In **Name**, enter the name of the team: either _Marketing_ or _Engineering_. + You do not need to enter an email. +1. Click **Create**. +1. Click on the **Teams** link at the top of the page to return to teams page and create the second team. + +#### Add a user to a team + +Repeat these steps for each user to assign them to their team. Refer to the table above for team assignments. + +1. Click the team name _Marketing_ or _Engineering_ to add members to that team. +1. Click **Add member**. +1. In the **Add team member** box, click the drop-down arrow to choose the user you want to add to the team . +1. Click **Add to team**. + +When you're done, you'll have two teams with two users assigned to each. + +## Manage resource access with folders + +It's a good practice to use folders to organize collections of related dashboards. You can assign permissions at the folder level to individual users or teams. + +### Exercise + +The Marketing team is going to use Grafana for analytics, while the Engineering team wants to monitor the application they're building. + +You'll create two folders, _Analytics_ and _Application_, where each team can add their own dashboards. The teams still want to be able to view each other's dashboards. + +| Folder | Team | Permissions | +| ----------- | ----------- | ----------- | +| Analytics | Marketing | Edit | +| | Engineering | View | +| Application | Marketing | View | +| | Engineering | Edit | + +Repeat the following steps for each folder. You'll move through all three steps for each folder before moving on to the next one. + +#### Add a folder for each team + +1. In the sidebar, hover your cursor over the **Dashboards** (four squares) icon and then click **Browse**. +1. To create a folder, click **New Folder**. +1. In **Name**, enter the folder name. +1. Click **Create**. +1. Stay in the folder view and move on to the next sections to edit permissions for this folder. + +#### Remove the viewer role from folder permissions + +By default, when you create a folder, all users with the Viewer role are granted permission to view the folder. + +In this example, Graphona wants to explicitly grant teams access to folders. To support this, you need to remove the Viewer role from the list of permissions: + +1. Go to the **Permissions** tab. +1. Remove the Viewer role from the list, by clicking the red button on the right. +1. Stay in the permissions tab and move on to the next section to grant folder permissions for each team. + +#### Grant folder permissions to a team: + +1. Click **Add Permission**. +1. In the **Add Permission For** dialog, make sure "Team" is selected in the first box. +1. In the second box, select the team to grant access to. +1. In the third box, select the access you want to grant. +1. Click **Save**. +1. Repeat for the other team. +1. Click the **Dashboards** link at the top of the page to return to the dashboard list. + +When you're finished, you'll have two empty folders, the contents of which can only be viewed by members of the Marketing or Engineering teams. Only Marketing team members can edit the contents of the Analytics folder, only Engineering team members can edit the contents of the Application folder. + +## Define granular permissions + +By using folders and teams, you avoid having to manage permissions for individual users. + +However, there are times when you need to configure permissions on a more granular level. For these cases, Grafana allows you to override permissions for specific dashboards. + +### Exercise + +Graphona has hired a consultant to assist the Marketing team. The consultant should only be able to access the SEO dashboard in the Analytics folder. + +| Name | Email | Username | +| ---------- | -------------------------------- | ---------- | +| Luc Masson | luc.masson@exampleconsulting.com | luc.masson | + +#### Add a new user + +1. In the sidebar, click the **Server Admin** (shield) icon. +1. In the Users tab, click **New user**. +1. In **Name**, enter the name of the user. +1. In **E-mail**, enter the email of the user. +1. In **Username**, enter the username that the user will use to log in. +1. In **Password**, enter a password. The user can change their password once they log in. +1. Click **Create user** to create the user account. + +#### Create a dashboard + +1. In the sidebar, click the **Create** (plus) icon to create a new dashboard. +1. In the top right corner, click the cog icon to go to **Dashboard settings**. +1. In **Name**, enter **SEO**. +1. Click **Save Dashboard**. +1. In the **Save dashboard as...** pop-up, choose the **Analytics** folder from the drop-down and click **Save**. + +#### Grant a user permission to view dashboard + +1. In the top right corner of your dashboard, click the cog icon to go to **Dashboard settings**. +1. Go to the **Permissions** tab, and click **Add Permission**. +1. In the **Add Permission For** dialog, select **User** in the first box. +1. In the second box, select the user to grant access to: Luc Masson. +1. In the third box, select **View**. +1. Click **Save**. +1. Click **Save dashboard**. +1. Add a note about giving Luc Masson Viewer permission for the dashboard and then click **Save**. + +You've created a new user and given them unique permissions to view a single dashboard within a folder. + +#### Check your work + +You can repeat these steps to log in as the other users you've created see the differences in the viewer and editor roles. + +For this example, you can log in as the user `luc.masson` to see that they can only access the SEO dashboard. + +1. Click the profile (avatar) button in the bottom left corner, choose **Sign out**. +1. Enter `luc.masson` as the username. +1. Enter the password you created for Luc. +1. Click **Log in**. +1. In the sidebar, hover your cursor over the **Dashboards** (four squares) icon and then click **Browse**. +1. You'll notice that you won't see the **Analytics** folder in the folder view because we did not give Luc folder permission. +1. Click on the list icon (3 lines) to see the dashboard list. +1. Click on the **SEO dashboard**, there shouldn't be any editing permissions since we assigned Luc the viewer role. + +## Summary + +In this tutorial, you've configured Grafana for an organization: + +- You added users to your organization. +- You created teams to manage permissions for groups of users. +- You configured permissions for folders and dashboard. + +### Learn more + +- [Organization Roles](/docs/grafana/next/administration/manage-users-and-permissions/about-users-and-permissions/#organization-roles) +- [Permissions Overview](/docs/grafana/latest/administration/manage-users-and-permissions/about-users-and-permissions/#about-users-and-permissions) diff --git a/docs/sources/tutorials/grafana-fundamentals/index.md b/docs/sources/tutorials/grafana-fundamentals/index.md new file mode 100644 index 00000000000..5374ac91ae0 --- /dev/null +++ b/docs/sources/tutorials/grafana-fundamentals/index.md @@ -0,0 +1,354 @@ +--- +title: Grafana fundamentals +summary: Get familiar with Grafana +description: Get familiar with Grafana +id: grafana-fundamentals +categories: ['fundamentals'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 10 +--- + +## Introduction + +In this tutorial, you'll learn how to use Grafana to set up a monitoring solution for your application. + +In this tutorial, you'll: + +- Explore metrics and logs +- Build dashboards +- Annotate dashboards +- Set up alerts + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- [Docker](https://docs.docker.com/install/) +- [Docker Compose](https://docs.docker.com/compose/) (included in Docker for Desktop for macOS and Windows) +- [Git](https://git-scm.com/) + {{% /class %}} + +## Set up the sample application + +This tutorial uses a sample application to demonstrate some of the features in Grafana. To complete the exercises in this tutorial, you need to download the files to your local machine. + +In this step, you'll set up the sample application, as well as supporting services, such as [Prometheus](https://prometheus.io/) and [Loki](/oss/loki/). + +1. Clone the [github.com/grafana/tutorial-environment](https://github.com/grafana/tutorial-environment) repository. + + ``` + git clone https://github.com/grafana/tutorial-environment.git + ``` + +1. Change to the directory where you cloned this repository: + + ``` + cd tutorial-environment + ``` + +1. Make sure Docker is running: + + ``` + docker ps + ``` + + No errors means it is running. If you get an error, then start Docker and then run the command again. + +1. Start the sample application: + + ``` + docker-compose up -d + ``` + + The first time you run `docker-compose up -d`, Docker downloads all the necessary resources for the tutorial. This might take a few minutes, depending on your internet connection. + + > **Note:** If you already have Grafana, Loki, or Prometheus running on your system, then you might see errors because the Docker image is trying to use ports that your local installations are already using. Stop the services, then run the command again. + +1. Ensure all services are up-and-running: + + ``` + docker-compose ps + ``` + + In the **State** column, it should say `Up` for all services. + +1. Browse to the sample application on [localhost:8081](http://localhost:8081). + +### Grafana News + +The sample application, Grafana News, lets you post links and vote for the ones you like. + +To add a link: + +1. In **Title**, enter **Example**. +1. In **URL**, enter **https://example.com**. +1. Click **Submit** to add the link. + + The link appears in the list under the Grafana News heading. + +To vote for a link, click the triangle icon next to the name of the link. + +## Log in to Grafana + +Grafana is an open-source platform for monitoring and observability that lets you visualize and explore the state of your systems. + +1. Open a new tab. +1. Browse to [localhost:3000](http://localhost:3000). +1. In **email or username**, enter **admin**. +1. In **password**, enter **admin**. +1. Click **Log In**. + + The first time you log in, you're asked to change your password: + +1. In **New password**, enter your new password. +1. In **Confirm new password**, enter the same password. +1. Click **Save**. + +The first thing you see is the Home dashboard, which helps you get started. + +To the far left you can see the _sidebar_, a set of quick access icons for navigating Grafana. + +## Add a metrics data source + +The sample application exposes metrics which are stored in [Prometheus](https://prometheus.io/), a popular time series database (TSDB). + +To be able to visualize the metrics from Prometheus, you first need to add it as a data source in Grafana. + +1. In the sidebar, hover your cursor over the **Configuration** (gear) icon, and then click **Data sources**. +1. Click **Add data source**. +1. In the list of data sources, click **Prometheus**. +1. In the URL box, enter **http\://prometheus:9090**. +1. Click **Save & test**. + + Prometheus is now available as a data source in Grafana. + +## Explore your metrics + +Grafana Explore is a workflow for troubleshooting and data exploration. In this step, you'll be using Explore to create ad-hoc queries to understand the metrics exposed by the sample application. + +> Ad-hoc queries are queries that are made interactively, with the purpose of exploring data. An ad-hoc query is commonly followed by another, more specific query. + +1. In the sidebar, click the **Explore** (compass) icon. +1. In the **Query editor**, where it says _Enter a PromQL query…_, enter `tns_request_duration_seconds_count` and then press Shift + Enter. + A graph appears. +1. In the top right corner, click the dropdown arrow on the **Run Query** button, and then select **5s**. Grafana runs your query and updates the graph every 5 seconds. + + You just made your first _PromQL_ query! [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) is a powerful query language that lets you select and aggregate time series data stored in Prometheus. + + `tns_request_duration_seconds_count` is a _counter_, a type of metric whose value only ever increases. Rather than visualizing the actual value, you can use counters to calculate the _rate of change_, i.e. how fast the value increases. + +1. Add the [`rate`](https://prometheus.io/docs/prometheus/latest/querying/functions/#rate) function to your query to visualize the rate of requests per second. Enter the following in the **Query editor** and then press Shift + Enter. + + ``` + rate(tns_request_duration_seconds_count[5m]) + ``` + + Immediately below the graph there's an area where each time series is listed with a colored icon next to it. This area is called the _legend_. + + PromQL lets you group the time series by their labels, using the [`sum`](https://prometheus.io/docs/prometheus/latest/querying/operators/#aggregation-operators) aggregation operator. + +1. Add the `sum` aggregation operator to your query to group time series by route: + + ``` + sum(rate(tns_request_duration_seconds_count[5m])) by(route) + ``` + +1. Go back to the [sample application](http://localhost:8081) and generate some traffic by adding new links, voting, or just refresh the browser. + +1. In the upper-right corner, click the _time picker_, and select **Last 5 minutes**. By zooming in on the last few minutes, it's easier to see when you receive new data. + +Depending on your use case, you might want to group on other labels. Try grouping by other labels, such as `status_code`, by changing the `by(route)` part of the query. + +## Add a logging data source + +Grafana supports log data sources, like [Loki](/oss/loki/). Just like for metrics, you first need to add your data source to Grafana. + +1. In the sidebar, hover your cursor over the **Configuration** (gear) icon, and then click **Data Sources**. +1. Click **Add data source**. +1. In the list of data sources, click **Loki**. +1. In the URL box, enter [http://loki:3100](http://loki:3100). +1. Click **Save & Test** to save your changes. + +Loki is now available as a data source in Grafana. + +## Explore your logs + +Grafana Explore not only lets you make ad-hoc queries for metrics, but lets you explore your logs as well. + +1. In the sidebar, click the **Explore** (compass) icon. +1. In the data source list at the top, select the **Loki** data source. +1. In the **Query editor**, enter: + + ``` + {filename="/var/log/tns-app.log"} + ``` + +1. Grafana displays all logs within the log file of the sample application. The height of each bar in the graph encodes the number of logs that were generated at that time. + +1. Click and drag across the bars in the graph to filter logs based on time. + +Not only does Loki let you filter logs based on labels, but on specific occurrences. + +Let's generate an error, and analyze it with Explore. + +1. In the [sample application](http://localhost:8081), post a new link without a URL to generate an error in your browser that says `empty url`. +1. Go back to Grafana and enter the following query to filter log lines based on a substring: + + ``` + {filename="/var/log/tns-app.log"} |= "error" + ``` + +1. Click on the log line that says `level=error msg="empty url"` to see more information about the error. + + > **Note:** If you're in Live mode, clicking logs will not show more information about the error. Instead, stop and exit the live stream, then click the log line there. + +Logs are helpful for understanding what went wrong. Later in this tutorial, you'll see how you can correlate logs with metrics from Prometheus to better understand the context of the error. + +## Build a dashboard + +A _dashboard_ gives you an at-a-glance view of your data and lets you track metrics through different visualizations. + +Dashboards consist of _panels_, each representing a part of the story you want your dashboard to tell. + +Every panel consists of a _query_ and a _visualization_. The query defines _what_ data you want to display, whereas the visualization defines _how_ the data is displayed. + +1. In the sidebar, hover your cursor over the **Create** (plus sign) icon and then click **Dashboard**. +1. Click **Add a new panel**. +1. In the **Query editor** below the graph, enter the query from earlier and then press Shift + Enter: + + ``` + sum(rate(tns_request_duration_seconds_count[5m])) by(route) + ``` + +1. In the **Legend** field, enter **{{route}}** to rename the time series in the legend. The graph legend updates when you click outside the field. +1. In the Panel editor on the right, under **Settings**, change the panel title to "Traffic". +1. Click **Apply** in the top-right corner to save the panel and go back to the dashboard view. +1. Click the **Save dashboard** (disk) icon at the top of the dashboard to save your dashboard. +1. Enter a name in the **Dashboard name** field and then click **Save**. + +## Annotate events + +When things go bad, it often helps if you understand the context in which the failure occurred. Time of last deploy, system changes, or database migration can offer insight into what might have caused an outage. Annotations allow you to represent such events directly on your graphs. + +In the next part of the tutorial, we will simulate some common use cases that someone would add annotations for. + +1. To manually add an annotation, click anywhere in your graph, then click **Add annotation**. +1. In **Description**, enter **Migrated user database**. +1. Click **Save**. + + Grafana adds your annotation to the graph. Hover your mouse over the base of the annotation to read the text. + +Grafana also lets you annotate a time interval, with _region annotations_. + +Add a region annotation: + +1. Press Ctrl (or Cmd on macOS), then click and drag across the graph to select an area. +1. In **Description**, enter **Performed load tests**. +1. In **Tags**, enter **testing**. + +Manually annotating your dashboard is fine for those single events. For regularly occurring events, such as deploying a new release, Grafana supports querying annotations from one of your data sources. Let's create an annotation using the Loki data source we added earlier. + +1. At the top of the dashboard, click the **Dashboard settings** (gear) icon. +1. Go to **Annotations** and click **Add annotation query**. +1. In **Name**, enter **Errors**. +1. In **Data source**, select **Loki**. +1. In **Query**, enter the following query: + + ``` + {filename="/var/log/tns-app.log"} |= "error" + ``` + + + +1. Click **Add**. Grafana displays the Annotations list, with your new annotation. +1. Click the **Go back** arrow to return to your dashboard. +1. At the top of your dashboard, there is now a toggle to display the results of the newly created annotation query. Press it so that it's enabled. + +The log lines returned by your query are now displayed as annotations in the graph. + +Being able to combine data from multiple data sources in one graph allows you to correlate information from both Prometheus and Loki. + +Annotations also work very well alongside alerts. In the next and final section, we will set up an alert for our app `grafana.news` and then we will trigger it. This will provide a quick intro to our new Alerting platform. + +## Create a Grafana Managed Alert + +Alerts allow you to identify problems in your system moments after they occur. By quickly identifying unintended changes in your system, you can minimize disruptions to your services. + +Grafana's new alerting platform debuted with Grafana 8. A year later, with Grafana 9, it became the default alerting method. In this step we will create a Grafana Managed Alert. Then we will trigger our new alert and send a test message to a dummy endpoint. + +The most basic alert consists of two parts: + +1. A _Contact Point_ - A Contact point defines how Grafana delivers an alert. When the conditions of an _alert rule_ are met, Grafana notifies the contact points, or channels, configured for that alert. Some popular channels include email, webhooks, Slack notifications, and PagerDuty notifications. +1. An _Alert rule_ - An Alert rule defines one or more _conditions_ that Grafana regularly evaluates. When these evaluations meet the rule's criteria, the alert is triggered. + +To begin, let's set up a webhook Contact Point. Once we have a usable endpoint, we'll write an alert rule and trigger a notification. + +### Create a Contact Point for Grafana Managed Alerts + +In this step, we'll set up a new Contact Point. This contact point will use the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert. We will use [requestbin.com](https://requestbin.com) to quickly set up that test endpoint. This way we can make sure that our alert is actually sending a notification somewhere. + +1. Browse to [requestbin.com](https://requestbin.com). +1. Under the **Create Request Bin** button, click the **public bin** link. + +Your request bin is now waiting for the first request. + +1. Copy the endpoint URL. + +Next, let's configure a Contact Point in Grafana's Alerting UI to send notifications to our Request Bin. + +1. Return to Grafana. In Grafana's sidebar, hover your cursor over the **Alerting** (bell) icon and then click **Contact points**. +1. Click **+ New contact point**. +1. In **Name**, write **RequestBin**. +1. In **Contact point type**, choose **Webhook**. +1. In **Url**, paste the endpoint to your request bin. +1. Click **Test** to send a test alert to your request bin. +1. Navigate back to the request bin you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent. +1. Return to Grafana and click **Save contact point**. + +We have now created a dummy webhook endpoint and created a new Alerting Contact Point in Grafana. Now we can create an alert rule and link it to this new channel. + +### Add an Alert Rule to Grafana + +Now that Grafana knows how to notify us, it's time to set up an alert rule: + +1. In Grafana's sidebar, hover the cursor over the **Alerting** (bell) icon and then click **Alert rules**. +1. Click **+ New alert rule**. +1. For **Section 1**, name the rule `fundamentals-test`, and set **Rule type** to **Grafana Managed Alert**. For **Folder** type `fundamentals` and in the box that appears, press **Create: fundamentals**. +1. For **Section 2**, find the **query A** box. Choose your Prometheus datasource and enter the same query that we used in our earlier panel: `sum(rate(tns_request_duration_seconds_count[5m])) by(route)`. Press **Run queries**. You should see some data in the graph. +1. Now scroll down to the **query B** box. For **Operation** choose `Classic condition`. [You can read more about classic and multi-dimensional conditions here](/docs/grafana/latest/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule/#single-and-multi-dimensional-rule). For conditions enter the following: `WHEN last() OF A IS ABOVE 0.2` +1. In **Section 3**, enter `30s` for the **Evaluate every** field. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. In the **for** field, enter `0m`. This setting makes Grafana wait until an alert has fired for a given time before Grafana sends the notification. +1. In **Section 4**, you can add some sample text to your summary message. [Read more about message templating here](/docs/grafana/latest/alerting/unified-alerting/message-templating/). +1. Click **Save and exit** at the top of the page. +1. In Grafana's sidebar, hover the cursor over the **Alerting** (bell) icon and then click **Notification policies**. +1. Under **Root policy**, press **Edit** and change the **Default contact point** to **RequestBin**. As a system grows, admins can use the **Notification policies** setting to organize and match alert rules to specific contact points. + +### Trigger a Grafana Managed Alert + +We have now configured an alert rule and a contact point. Now let's see if we can trigger a Grafana Managed Alert by generating some traffic on our sample application. + +1. Browse to [localhost:8081](http://localhost:8081). +1. Repeatedly click the vote button or refresh the page to generate a traffic spike. + +Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana will trigger our alert. Browse to the Request Bin we created earlier and find the sent Grafana alert notification with details and metadata. + +## Summary + +In this tutorial you learned about fundamental features of Grafana. To do so, we ran several Docker containers on your local machine. When you are ready to clean up this local tutorial environment, run the following command: + +``` +docker-compose down -v +``` + +### Learn more + +Check out the links below to continue your learning journey with Grafana's LGTM stack. + +- [Prometheus](/docs/grafana/latest/features/datasources/prometheus/) +- [Loki](/docs/grafana/latest/features/datasources/loki/) +- [Explore](/docs/grafana/latest/features/explore/) +- [Alerting Overview](/docs/grafana/latest/alerting/) +- [Alert rules](/docs/grafana/latest/alerting/create-alerts/) +- [Contact Points](/docs/grafana/latest/alerting/notifications/) diff --git a/docs/sources/tutorials/iis/index.md b/docs/sources/tutorials/iis/index.md new file mode 100644 index 00000000000..ec4a190a128 --- /dev/null +++ b/docs/sources/tutorials/iis/index.md @@ -0,0 +1,146 @@ +--- +title: Use IIS with URL Rewrite as a reverse proxy +summary: Learn how to set up Grafana behind IIS with URL Rewrite. +description: Learn how to set up Grafana behind IIS with URL Rewrite. +id: iis +categories: ['administration'] +tags: ['advanced'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +aliases: ['/docs/grafana/latest/tutorials/iis/'] +--- + +# Use IIS with URL Rewrite as a reverse proxy + +If you want Grafana to be a subpath/subfolder under a website in IIS then the Application Request Routing (ARR) and URL Rewrite modules for ISS can be used to support this. + +Example: + +- Parent site: http://yourdomain.com:8080 +- Grafana: http://localhost:3000 + +Grafana as a subpath: http://yourdomain.com:8080/grafana + +Other Examples: + +- If the application is only served on the local server, the parent site can also look like http://localhost:8080. +- If your domain is served using https on port 443, and thus the port is not normally entered in the address of your site, then the need to specify a port for the parent site in the configuration steps below can be eliminated. + +## Setup + +Install the URL Rewrite module for IIS. + +- Download and install the URL Rewrite module for IIS: https://www.iis.net/downloads/microsoft/url-rewrite + +You will also need the Application Request Routing (ARR) module for IIS for proxy forwarding + +- Download and install ARR module for IIS: https://www.iis.net/downloads/microsoft/application-request-routing + +## Grafana Config + +The Grafana config can be set by creating a file named/editing the existing file named `custom.ini` in the `conf` subdirectory of your Grafana installation. See the [installation instructions](http://docs.grafana.org/installation/windows/#configure) for more details. + +Using the example from above, if the subpath is `grafana` (you can set this to whatever is required) and the parent site is `yourdomain.com:8080`, then you would add this to the `custom.ini` config file: + +```bash +[server] +domain = yourdomain.com:8080 +root_url = %(protocol)s://%(domain)s/grafana/ +``` + +Restart the Grafana server after changing the config file. + +Configured address to serve Grafana: http://yourdomain.com:8080/grafana + +--- + +If you already have a subpath on your domain, configure it as follows: + +- Your Parent Site Address: http://yourdomain.com/existingsubpath + +```bash +[server] +domain = yourdomain.com/existingsubpath +root_url = %(protocol)s://%(domain)s/grafana/ +``` + +Restart the Grafana server after changing the config file. + +Configured address to serve Grafana: http://yourdomain.com/existingsubpath/grafana + +## IIS Config + +### Step 1: Forward Proxy + +1. Open the IIS Manager and click on the server +2. In the admin console for the server, double click on the Application Request Routing option: +3. Click the `Server Proxy Settings` action on the right-hand pane +4. Select the `Enable proxy` checkbox so that it is enabled +5. Click `Apply` and proceed with the URL Rewriting configuration + +**Note:** If you don't enable the Forward Proxy, you will most likely get 404 Not Found if you only apply the URL Rewrite rule + +### Step 2: URL Rewriting + +1. In the IIS Manager, click on the website that grafana will run under. For example, select the website that is bound to the http://yourdomain.com domain. +2. In the admin console for this website, double click on the URL Rewrite option: + +{{< figure src="/static/img/docs/tutorials/IIS_admin_console.png" max-width="800px" >}} + +3. Click on the `Add Rule(s)...` action +4. Choose the Blank Rule template for an Inbound Rule + +{{< figure src="/static/img/docs/tutorials/IIS_add_inbound_rule.png" max-width="800px" >}} + +5. Create an Inbound Rule for the website with the following settings: + +- pattern: `grafana(/)?(.*)` (if you have customised the subpath that will be used, use that instead of `grafana`) +- check the `Ignore case` checkbox +- rewrite URL set to `http://localhost:3000/{R:2}` +- check the `Append query string` checkbox +- check the `Stop processing of subsequent rules` checkbox + +{{< figure src="/static/img/docs/tutorials/IIS_url_rewrite.png" max-width="800px" >}} + +6. If your version of Grafana is greater than 8.3.5, you also need to configure the reverse proxy to preserve host headers. + +- This can be achieved by configuring the IIS config file by running this in a cmd prompt + `%windir%\system32\inetsrv\appcmd.exe set config -section:system.webServer/proxy -preserveHostHeader:true /commit:apphost` +- More information here https://github.com/grafana/grafana/issues/45261 + +Finally, navigate to `http://yourdomain.com:8080/grafana` and you should come to the Grafana login page. + +## Troubleshooting + +### 404 error + +When navigating to the Grafana URL (`http://yourdomain.com:8080/grafana`) and a `HTTP Error 404.0 - Not Found` error is returned, then either: + +- The pattern for the Inbound Rule is incorrect. Edit the rule, click on the `Test pattern...` button, test the part of the URL after `http://yourdomain.com:8080/` and make sure it matches. For `grafana/login` the test should return 3 capture groups: {R:0}: `grafana` {R:1}: `/` and {R:2}: `login`. +- The `root_url` setting in the Grafana config file does not match the parent URL with subpath. + +### Grafana Website only shows text with no images or css + +{{< figure src="/static/img/docs/tutorials/IIS_proxy_error.png" max-width="800px" >}} + +1. The `root_url` setting in the Grafana config file does not match the parent URL with subpath. This could happen if the root_url is commented out by mistake (`;` is used for commenting out a line in .ini files): + + `; root_url = %(protocol)s://%(domain)s/grafana/` + +2. or if the subpath in the `root_url` setting does not match the subpath used in the pattern in the Inbound Rule in IIS: + + `root_url = %(protocol)s://%(domain)s/grafana/` + + pattern in Inbound Rule: `wrongsubpath(/)?(.*)` + +3. or if the Rewrite URL in the Inbound Rule is incorrect. + + The Rewrite URL should not include the subpath. + + The Rewrite URL should contain the capture group from the pattern matching that returns the part of the URL after the subpath. The pattern used above returns three capture groups and the third one {R:2} returns the part of the URL after `http://yourdomain.com:8080/grafana/`. + +### You see an 'Error updating options: origin not allowed' error + +- Ensure you have undertaken step 6 above, to configure IIS to preserve host headers by edit IIS config by running this in cmd prompt: + `%windir%\system32\inetsrv\appcmd.exe set config -section:system.webServer/proxy -preserveHostHeader:true /commit:apphost` diff --git a/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md b/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md new file mode 100644 index 00000000000..16870ac0f92 --- /dev/null +++ b/docs/sources/tutorials/install-grafana-on-raspberry-pi/index.md @@ -0,0 +1,147 @@ +--- +title: Install Grafana on Raspberry Pi +summary: Get Grafana set up on your Raspberry Pi. +description: Get Grafana set up on your Raspberry Pi. +id: install-grafana-on-raspberry-pi +categories: ['administration'] +tags: ['beginner'] +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +--- + +## Introduction + +The Raspberry Pi is a tiny, affordable, yet capable computer that can run a range of different applications. Even Grafana! + +Many people are running Grafana on Raspberry Pi as a way to monitor their home, for things like indoor temperature, humidity, or energy usage. + +In this tutorial, you'll: + +- Set up a Raspberry Pi using a version of Raspberry Pi OS (previously called "Raspbian") that does not require you to connect a keyboard or monitor (this is often called "headless"). +- Install Grafana on your Raspberry Pi. + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Raspberry Pi +- SD card + {{% /class %}} + +## Set up your Raspberry Pi + +Before we can install Grafana, you first need to set up your Raspberry Pi. + +For this tutorial, you'll configure your Raspberry Pi to be _headless_. This means you don't need to connect a monitor, keyboard, or mouse to your Raspberry Pi. All configuration is done from your regular computer. + +#### Download and install Raspberry Pi Imager + +Before we get started, you need to download and install the [Raspberry Pi Imager](https://www.raspberrypi.org/software/). + +We'll use the Raspberry Pi Imager to flash the operating system image to the SD card. You download the imager directly from the official Raspberry Pi website and it's available for Ubuntu Linux, macOS, and Windows. + +Follow the directions on the website to download and install the imager. + +#### Install Raspberry Pi OS + +Now it is time to install Raspberry Pi OS. + +1. Insert the SD card into your regular computer from which you plan to install Raspberry Pi OS. +1. Run the Raspberry Pi Imager that you downloaded and installed. +1. To select an operating system, click **Choose OS** in the imager. You will be shown a list of available options. +1. From the list, select **Raspberry Pi OS (other)** and then select **Raspberry Pi OS Lite**, which is a Debian-based operating system for the Raspberry Pi. Since you're going to run a headless Raspberry Pi, you won't need the desktop dependencies. +1. To select where you want to put the operating system image, click **Choose Storage** in the imager and then select the SD card you already inserted into your computer. +1. The final step in the imager to click **Write**. When you do, the imager will write the Raspberry Pi OS Lite image to the SD card and verify that it has been written correctly. +1. Eject the SD card from your computer, and insert it again. + +While you _could_ fire up the Raspberry Pi now, we don't yet have any way of accessing it. + +1. Create an empty file called `ssh` in the boot directory. This enables SSH so that you can log in remotely. + + The next step is only required if you want the Raspberry Pi to connect to your wireless network. Otherwise, connect the it to your network by using a network cable. + +1. **(Optional)** Create a file called `wpa_supplicant.conf` in the boot directory: + + ``` + ctrl_interface=/var/run/wpa_supplicant + update_config=1 + country= + + network={ + ssid="" + psk="" + } + ``` + +All the necessary files are now on the SD card. Let's start up the Raspberry Pi. + +1. Eject the SD card and insert it into the SD card slot on the Raspberry Pi. +1. Connect the power cable and make sure the LED lights are on. +1. Find the IP address of the Raspberry Pi. Usually you can find the address in the control panel for your WiFi router. + +#### Connect remotely via SSH + +1. Open up your terminal and enter the following command: + ``` + ssh pi@ + ``` +1. SSH warns you that the authenticity of the host can't be established. Type "yes" to continue connecting. +1. When asked for a password, enter the default password: `raspberry`. +1. Once you're logged in, change the default password: + ``` + passwd + ``` + +Congratulations! You've now got a tiny Linux machine running that you can hide in a closet and access from your normal workstation. + +## Install Grafana + +Now that you've got the Raspberry Pi up and running, the next step is to install Grafana. + +1. Add the APT key used to authenticate packages: + + ``` + wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add - + ``` + +1. Add the Grafana APT repository: + + ``` + echo "deb https://packages.grafana.com/oss/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list + ``` + +1. Install Grafana: + ``` + sudo apt-get update + sudo apt-get install -y grafana + ``` + +Grafana is now installed, but not yet running. To make sure Grafana starts up even if the Raspberry Pi is restarted, we need to enable and start the Grafana Systemctl service. + +1. Enable the Grafana server: + + ``` + sudo /bin/systemctl enable grafana-server + ``` + +1. Start the Grafana server: + + ``` + sudo /bin/systemctl start grafana-server + ``` + + Grafana is now running on the machine and is accessible from any device on the local network. + +1. Open a browser and go to `http://:3000`, where the IP address is the address that you used to connect to the Raspberry Pi earlier. You're greeted with the Grafana login page. +1. Log in to Grafana with the default username `admin`, and the default password `admin`. +1. Change the password for the admin user when asked. + +Congratulations! Grafana is now running on your Raspberry Pi. If the Raspberry Pi is ever restarted or turned off, Grafana will start up whenever the machine regains power. + +## Summary + +If you want to use Grafana without having to go through a full installation process, check out [Grafana Cloud](/products/cloud/), which is designed to get users up and running quickly and easily. Grafana Cloud offers a forever free plan that is genuinely useful for hobbyists, testing, and small teams. + +### Learn more + +- [Raspberry Pi Documentation](https://www.raspberrypi.org/documentation/) diff --git a/docs/sources/tutorials/integrate-hubot/index.md b/docs/sources/tutorials/integrate-hubot/index.md new file mode 100644 index 00000000000..0d0af2f3821 --- /dev/null +++ b/docs/sources/tutorials/integrate-hubot/index.md @@ -0,0 +1,118 @@ +--- +title: Integrate Hubot with Grafana +summary: Learn how to integrate Hubot with Grafana +description: Learn how to integrate Hubot with Grafana +id: integrate-hubot +categories: ['administration'] +tags: ['advanced'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +aliases: ['/docs/grafana/latest/tutorials/hubot_howto/'] +--- + +# Integrate Hubot with Grafana + +Grafana 2.0 shipped with a great feature that enables it to render any graph or panel to a PNG image. + +No matter what data source you are using, the PNG image of the Graph will look the same as it does in your browser. + +This guide will show you how to install and configure the [Hubot-Grafana](https://github.com/stephenyeargin/hubot-grafana) plugin. This plugin allows you to tell hubot to render any dashboard or graph right from a channel in Slack, Hipchat or Basecamp. The bot will respond with an image of the graph and a link that will take you to the graph. + +> _Amazon S3 Required_: The hubot-grafana script will upload the rendered graphs to Amazon S3. This +> is so Hipchat and Slack can show them reliably (they require the image to be publicly available). + +{{< figure src="/static/img/docs/tutorials/hubot_grafana.png" max-width="800px" >}} + +## What is Hubot? + +[Hubot](https://hubot.github.com/) is an universal and extensible chat bot that can be used with many chat services and has a huge library of third party plugins that allow you to automate anything from your chat rooms. + +## Install Hubot + +Hubot is very easy to install and host. If you do not already have a bot up and running please read the official [Getting Started With Hubot](https://hubot.github.com/docs/) guide. + +## Install Hubot-Grafana script + +In your Hubot project repo install the Grafana plugin using `npm`: + +```bash +npm install hubot-grafana --save +``` + +Edit the file external-scripts.json, and add hubot-grafana to the list of plugins. + +```json +["hubot-pugme", "hubot-shipit", "hubot-grafana"] +``` + +## Configure + +The `hubot-grafana` plugin requires a number of environment variables to be set in order to work properly. + +```bash +export HUBOT_GRAFANA_HOST=https://play.grafana.org +export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 +export HUBOT_GRAFANA_S3_BUCKET=mybucket +export HUBOT_GRAFANA_S3_ACCESS_KEY_ID=ABCDEF123456XYZ +export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234 +export HUBOT_GRAFANA_S3_PREFIX=graphs +export HUBOT_GRAFANA_S3_REGION=us-standard +``` + +### Grafana server side rendering + +The hubot plugin will take advantage of the Grafana server side rendering feature that can render any panel on the server using phantomjs. Grafana ships with a phantomjs binary (Linux only). + +To verify that this feature works try the `Direct link to rendered image` link in the panel share dialog. If you do not get an image when opening this link verify that the required font packages are installed for phantomjs to work. + +### Grafana API Key + +{{< figure src="/static/img/docs/v2/orgdropdown_api_keys.png" max-width="150px" class="docs-image--right">}} + +You need to set the environment variable `HUBOT_GRAFANA_API_KEY` to a Grafana API Key. You can add these from the API Keys page which you find in the Organization dropdown. + +### Amazon S3 + +The `S3` options are optional but for the images to work properly in services like Slack and Hipchat they need to publicly available. By specifying the `S3` options the hubot-grafana script will publish the rendered panel to `S3` and it will use that URL when it posts to Slack or Hipchat. + +## Hubot commands + +- `hubot graf list` + - Lists the available dashboards +- `hubot graf db graphite-carbon-metrics` + - Graph all panels in the dashboard +- `hubot graf db graphite-carbon-metrics:3` + - Graph only panel with id 3 of a particular dashboard +- `hubot graf db graphite-carbon-metrics:cpu` + - Graph only the panels containing "cpu" (case insensitive) in the title +- `hubot graf db graphite-carbon-metrics now-12hr` + - Get a dashboard with a window of 12 hours ago to now +- `hubot graf db graphite-carbon-metrics now-24hr now-12hr` + - Get a dashboard with a window of 24 hours ago to 12 hours ago +- `hubot graf db graphite-carbon-metrics:3 now-8d now-1d` + - Get only the third panel of a particular dashboard with a window of 8 days ago to yesterday +- `hubot graf db graphite-carbon-metrics host=carbon-a` + - Get a templated dashboard with the `$host` parameter set to `carbon-a` + +## Aliases + +Some of the hubot commands above can lengthy and you might have to remember the dashboard slug (url id). If you have a few favorite graphs you want to be able check up on often (let's say from your mobile) you can create hubot command aliases with the hubot script `hubot-alias`. + +Install it: + +```bash +npm i --save hubot-alias +``` + +Now add `hubot-alias` to the list of plugins in `external-scripts.json` and restart hubot. + +Now you can add an alias like this: + +- `hubot alias graf-lb=graf db loadbalancers:2 now-20m` + +{{< figure src="/static/img/docs/tutorials/hubot_grafana2.png" max-width="800px" >}} + +## Summary + +Grafana is going to ship with integrated Slack and Hipchat features some day but you do not have to wait for that. Grafana 2 shipped with a very clever server side rendering feature that can render any panel to a png using phantomjs. The hubot plugin for Grafana is something you can install and use today! diff --git a/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md b/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md new file mode 100644 index 00000000000..4554b67073f --- /dev/null +++ b/docs/sources/tutorials/provision-dashboards-and-data-sources/index.md @@ -0,0 +1,260 @@ +--- +title: Provision dashboards and data sources +summary: Treat your configuration as code. +description: Treat your configuration as code. +id: provision-dashboards-and-data-sources +categories: ['administration'] +tags: ['intermediate'] +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 40 +--- + +## Introduction + +Learn how you can reuse dashboards and data sources across multiple teams by provisioning Grafana from version-controlled configuration files. + +In this tutorial, you'll: + +- Provision dashboards. +- Provision data sources. + +{{% class "prerequisite-section" %}} + +### Prerequisites + +- Grafana 7.0 +- Administrator privileges on the system you are doing the tutorial on + {{% /class %}} + +## Configuration as code + +Configuration as code is the practice of storing the configuration of your system as a set of version controlled, human-readable configuration files, rather than in a database. These configuration files can be reused across environments to avoid duplicated resources. + +As the number of dashboards and data sources grows within your organization, manually managing changes can become tedious and error-prone. Encouraging reuse becomes important to avoid multiple teams redesigning the same dashboards. + +Grafana supports configuration as code through _provisioning_. The resources that currently supports provisioning are: + +- [Dashboards](/docs/grafana/latest/administration/provisioning/#dashboards) +- [Data sources](/docs/grafana/latest/administration/provisioning/#datasources) +- [Alert notification channels](/docs/grafana/latest/administration/provisioning/#alert-notification-channels) + +## Set the provisioning directory + +Before you can start provisioning resources, Grafana needs to know where to find the _provisioning directory_. The provisioning directory contains configuration files that are applied whenever Grafana starts and continuously updated while running. + +By default, Grafana looks for a provisioning directory in the configuration directory (grafana > conf) on the system where Grafana is installed. However, if you are a Grafana Administrator, then you might want to place the config files in a shared resource like a network folder, so you would need to change the path to the provisioning directory. + +You can set a different path by setting the `paths.provisioning` property in the main config file: + +```ini +[paths] +provisioning = +``` + +For more information about configuration files, refer to [Configuration](/docs/grafana/latest/installation/configuration/) in the [Grafana documentation](/docs/grafana/latest/). + +The provisioning directory assumes the following structure: + +``` +provisioning/ + datasources/ + + dashboards/ + + notifiers/ + +``` + +Next, we'll look at how to provision a data source. + +## Provision a data source + +Each data source provisioning config file contains a _manifest_ that specifies the desired state of a set of provisioned data sources. + +At startup, Grafana loads the configuration files and provisions the data sources listed in the manifests. + +Let's configure a [TestData DB](/docs/grafana/latest/features/datasources/testdata/) data source that you can use for your dashboards. + +#### Create a data source manifest + +1. In the `provisioning/datasources/` directory, create a file called `default.yaml` with the following content: + + ```yaml + apiVersion: 1 + + datasources: + - name: TestData DB + type: testdata + ``` + +1. Restart Grafana to load the new changes. +1. In the sidebar, hover the cursor over the **Configuration** (gear) icon and click **Data Sources**. The TestData DB appears in the list of data sources. + +> The configuration options can vary between different types of data sources. For more information on how to configure a specific data source, refer to [Data sources](/docs/grafana/latest/administration/provisioning/#datasources). + +## Provision a dashboard + +Each dashboard config file contains a manifest that specifies the desired state of a set of _dashboard providers_. + +A dashboard provider tells Grafana where to find the dashboard definitions and where to put them. + +Grafana regularly checks for changes to the dashboard definitions (by default every 10 seconds). + +Let's define a dashboard provider so that Grafana knows where to find the dashboards we want to provision. + +#### Define a dashboard provider + +In the `provisioning/dashboards/` directory, create a file called `default.yaml` with the following content: + +```yaml +apiVersion: 1 + +providers: + - name: Default # A uniquely identifiable name for the provider + folder: Services # The folder where to place the dashboards + type: file + options: + path: + + # Default path for Windows: C:/Program Files/GrafanaLabs/grafana/public/dashboards + # Default path for Linux is: /var/lib/grafana/dashboards +``` + +For more information on how to configure dashboard providers, refer to [Dashboards](/docs/grafana/latest/administration/provisioning/#dashboards). + +#### Create a dashboard definition + +1. In the dashboard definitions directory you specified in the dashboard provider, i.e. `options.path`, create a file called `cluster.json` with the following content: + + ```json + { + "__inputs": [], + "__requires": [], + "annotations": { + "list": [] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "TestData DB", + "fill": 1, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CPU Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [], + "schemaVersion": 16, + "style": "dark", + "tags": ["kubernetes"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "browser", + "title": "Cluster", + "version": 0 + } + ``` + +1. Restart Grafana to provision the new dashboard or wait 10 seconds for Grafana to automatically create the dashboard. +1. In the sidebar, hover the cursor over **Dashboards** (squares) icon, and then click **Manage**. The dashboard appears in a **Services** folder. + +> If you don't specify an `id` in the dashboard definition, then Grafana assigns one during provisioning. You can set the `id` yourself if you want to reference the dashboard from other dashboards. Be careful to not use the same `id` for multiple dashboards, as this will cause a conflict. + +## Summary + +In this tutorial you learned how you to reuse dashboards and data sources across multiple teams by provisioning Grafana from version-controlled configuration files. + +Dashboard definitions can get unwieldy as more panels and configurations are added to them. There are a number of open source tools available to make it easier to manage dashboard definitions: + +- [grafana-dash-gen](https://github.com/uber/grafana-dash-gen) (Javascript) +- [grafanalib](https://github.com/weaveworks/grafanalib) (Python) +- [grafonnet-lib](https://github.com/grafana/grafonnet-lib) (Jsonnet) +- [grafyaml](https://docs.openstack.org/infra/grafyaml/) (YAML) + +### Learn more + +- [Provisioning Grafana](/docs/grafana/latest/administration/provisioning/) diff --git a/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md new file mode 100644 index 00000000000..10be2dee35f --- /dev/null +++ b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md @@ -0,0 +1,222 @@ +--- +title: Run Grafana behind a reverse proxy +summary: Learn how to run Grafana behind a reverse proxy +description: Learn how to run Grafana behind a reverse proxy +id: run-grafana-behind-a-proxy +categories: ['administration'] +tags: ['advanced'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +aliases: ['/docs/grafana/latest/installation/behind_proxy/'] +--- + +## Introduction + +In this tutorial, you'll configure Grafana to run behind a reverse proxy. + +When running Grafana behind a proxy, you need to configure the domain name to let Grafana know how to render links and redirects correctly. + +- In the Grafana configuration file, change `server.domain` to the domain name you'll be using: + +```bash +[server] +domain = example.com +``` + +- Restart Grafana for the new changes to take effect. + +You can also serve Grafana behind a _sub path_, such as `http://example.com/grafana`. + +To serve Grafana behind a sub path: + +- Include the sub path at the end of the `root_url`. +- Set `serve_from_sub_path` to `true`. + +```bash +[server] +domain = example.com +root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ +serve_from_sub_path = true +``` + +Next, you need to configure your reverse proxy. + +## Configure NGINX + +[NGINX](https://www.nginx.com) is a high performance load balancer, web server, and reverse proxy. + +- In your NGINX configuration file inside `http` section, add the following: + +```nginx +# this is required to proxy Grafana Live WebSocket connections. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +upstream grafana { + server localhost:3000; +} + +server { + listen 80; + root /usr/share/nginx/html; + index index.html index.htm; + + location / { + proxy_set_header Host $http_host; + proxy_pass http://grafana; + } + + # Proxy Grafana Live WebSocket connections. + location /api/live/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_pass http://grafana; + } +} +``` + +- Reload the NGINX configuration. +- Navigate to port 80 on the machine NGINX is running on. You're greeted by the Grafana login page. + +For Grafana Live which uses WebSocket connections you may have to raise Nginx [worker_connections](https://nginx.org/en/docs/ngx_core_module.html#worker_connections) option which is 512 by default – which limits the number of possible concurrent connections with Grafana Live. + +Also, be aware that the above configuration will work only when the `proxy_pass` value for `location /` is a literal string. If you are using a variable here, [read this GitHub issue](https://github.com/grafana/grafana/issues/18299). You will need to add [an appropriate NGINX rewrite rule](https://www.nginx.com/blog/creating-nginx-rewrite-rules/). + +To configure NGINX to serve Grafana under a _sub path_, update the `location` block: + +```nginx +# this is required to proxy Grafana Live WebSocket connections. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +upstream grafana { + server localhost:3000; +} + +server { + listen 80; + root /usr/share/nginx/www; + index index.html index.htm; + + location /grafana/ { + rewrite ^/grafana/(.*) /$1 break; + proxy_set_header Host $http_host; + proxy_pass http://grafana; + } + + # Proxy Grafana Live WebSocket connections. + location /grafana/api/live/ { + rewrite ^/grafana/(.*) /$1 break; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_pass http://grafana; + } +} +``` + +## Configure HAProxy + +To configure HAProxy to serve Grafana under a _sub path_: + +```bash +frontend http-in + bind *:80 + use_backend grafana_backend if { path /grafana } or { path_beg /grafana/ } + +backend grafana_backend + # Requires haproxy >= 1.6 + http-request set-path %[path,regsub(^/grafana/?,/)] + + # Works for haproxy < 1.6 + # reqrep ^([^\ ]*\ /)grafana[/]?(.*) \1\2 + + server grafana localhost:3000 +``` + +## Configure IIS + +> IIS requires that the URL Rewrite module is installed. + +To configure IIS to serve Grafana under a _sub path_, create an Inbound Rule for the parent website in IIS Manager with the following settings: + +- pattern: `grafana(/)?(.*)` +- check the `Ignore case` checkbox +- rewrite URL set to `http://localhost:3000/{R:2}` +- check the `Append query string` checkbox +- check the `Stop processing of subsequent rules` checkbox + +This is the rewrite rule that is generated in the `web.config`: + +```xml + + + + + + + + +``` + +See the [tutorial on IIS URL Rewrites](/tutorials/iis/) for more in-depth instructions. + +## Configure Traefik + +[Traefik](https://traefik.io/traefik/) Cloud Native Reverse Proxy / Load Balancer / Edge Router + +Using the docker provider the following labels will configure the router and service for a domain or subdomain routing. + +```yaml +labels: + traefik.http.routers.grafana.rule: Host(`grafana.example.com`) + traefik.http.services.grafana.loadbalancer.server.port: 3000 +``` + +To deploy on a _sub path_ + +```yaml +labels: + traefik.http.routers.grafana.rule: Host(`example.com`) && PathPrefix(`/grafana`) + traefik.http.services.grafana.loadbalancer.server.port: 3000 +``` + +Examples using the file provider. + +```yaml +http: + routers: + grafana: + rule: Host(`grafana.example.com`) + service: grafana + services: + grafana: + loadBalancer: + servers: + - url: http://192.168.30.10:3000 +``` + +```yaml +http: + routers: + grafana: + rule: Host(`example.com`) && PathPrefix(`/grafana`) + service: grafana + services: + grafana: + loadBalancer: + servers: + - url: http://192.168.30.10:3000 +``` + +## Summary + +In this tutorial you learned how to run Grafana behind a reverse proxy. diff --git a/docs/sources/tutorials/stream-metrics-from-telegraf-to-grafana/index.md b/docs/sources/tutorials/stream-metrics-from-telegraf-to-grafana/index.md new file mode 100644 index 00000000000..822006b2fcf --- /dev/null +++ b/docs/sources/tutorials/stream-metrics-from-telegraf-to-grafana/index.md @@ -0,0 +1,101 @@ +--- +title: Stream metrics from Telegraf to Grafana +summary: Use Telegraf to stream live metrics to Grafana. +description: Use Telegraf to stream live metrics to Grafana. +id: stream-metrics-from-telegraf-to-grafana +categories: ['administration'] +tags: ['beginner'] +status: Published +authors: ['grafana_labs'] +Feedback Link: https://github.com/grafana/tutorials/issues/new +weight: 75 +--- + +## Introduction + +Grafana v8 introduced streaming capabilities – a way to push data to UI panels in near real-time. In this tutorial we show how Grafana real-time streaming capabilities can be used together with Telegraf to instantly display system measurements. + +In this tutorial, you'll: + +- Setup Telegraf and output measurements directly to Grafana time-series panel in near real-time + +{{% class "prerequisite-section" %}} + +#### Prerequisites + +- Grafana 8.0+ +- Telegraf + {{% /class %}} + +## Run Grafana and create admin token + +1. Run Grafana following [installation instructions](/docs/grafana/latest/installation/) for your operating system +1. Log in and go to Configuration -> API Keys +1. Press "Add API key" button and create a new API token with **Admin** role + +## Configure and run Telegraf + +Telegraf is a plugin-driven server agent for collecting and sending metrics and events from databases, systems, and IoT sensors. + +You can install it following [official installation instructions](https://docs.influxdata.com/telegraf/latest/introduction/installation/). + +In this tutorial we will be using Telegraf HTTP output plugin to send metrics in Influx format to Grafana. We can use a configuration like this: + +``` +[agent] + interval = "1s" + flush_interval = "1s" + +[[inputs.cpu]] + percpu = false + totalcpu = true + +[[outputs.http]] + url = "http://localhost:3000/api/live/push/custom_stream_id" + data_format = "influx" + [outputs.http.headers] + Authorization = "Bearer " +``` + +Make sure to replace `` placeholder with your actual API key created in the previous step. Save this config into `telegraf.conf` file and run Telegraf pointing to this config file. Telegraf will periodically (once in a second) report the state of total CPU usage on a host to Grafana (which is supposed to be running on `http://localhost:3000`). Of course you can replace `custom_stream_id` to something more meaningful for your use case. + +Inside Grafana Influx data is converted to Grafana data frames and then frames are published to Grafana Live channels. In this case, the channel where CPU data will be published is `stream/custom_stream_id/cpu`. The `stream` scope is constant, the `custom_stream_id` namespace is the last part of API URL set in Telegraf configuration (`http://localhost:3000/api/live/push/telegraf`) and the path is `cpu` - the name of a measurement. + +The only thing left here is to create a dashboard with streaming data. + +## Create dashboard with streaming data + +1. Create new dashboard +1. Press Add empty panel +1. Select `-- Grafana --` datasource +1. Select `Live Measurements` query type +1. Find and select `stream/custom_stream_id/cpu` measurement for Channel field +1. Save dashboard changes + +After making these steps Grafana UI should subscribe to the channel `stream/custom_stream_id/cpu` and you should see CPU data updates coming from Telegraf in near real-time. + +## Stream using WebSocket endpoint + +If you aim for a high-frequency update sending then you may want to use the WebSocket output plugin of Telegraf (introduced in Telegraf v1.19.0) instead of the HTTP output plugin we used above. Configure WebSocket output plugin like this: + +``` +[agent] + interval = "500ms" + flush_interval = "500ms" + +[[inputs.cpu]] + percpu = false + totalcpu = true + +[[outputs.websocket]] + url = "ws://localhost:3000/api/live/push/custom_stream_id" + data_format = "influx" + [outputs.websocket.headers] + Authorization = "Bearer " +``` + +WebSocket avoids running all Grafana HTTP middleware on each request from Telegraf thus reducing Grafana backend CPU usage significantly. + +## Summary + +In this tutorial you learned how to use Telegraf to stream live metrics to Grafana. diff --git a/e2e/run-suite b/e2e/run-suite index d74a41d67f4..8c446f3145b 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -18,6 +18,7 @@ args=("$@") CMD="start" PARAMS="" +CLEANUP="" declare -A env=( [BASE_URL]=${BASE_URL:-"http://$HOST:$PORT"} @@ -25,6 +26,7 @@ declare -A env=( ) testFilesForSingleSuite="*.spec.ts" +rootForEnterpriseSuite="extensions-suite" declare -A cypressConfig=( [integrationFolder]=../../e2e @@ -64,6 +66,27 @@ case "$1" in cypressConfig[screenshotsFolder]=../../e2e/benchmarks/"${args[1]}"/screenshots cypressConfig[testFiles]=$testFilesForSingleSuite ;; + "enterprise") + echo "Enterprise" + CLEANUP="rm -rf ../../e2e/extensions-suite" + SETUP="cp -Lr ../../e2e/extensions ../../e2e/extensions-suite" + enterpriseSuite=$(basename "${args[1]}") + case "$2" in + "debug") + echo -e "Debug mode" + env[SLOWMO]=1 + PARAMS="--no-exit" + enterpriseSuite=$(basename "${args[2]}") + ;; + "dev") + echo "Dev mode" + CMD="open" + enterpriseSuite=$(basename "${args[2]}") + ;; + esac + cypressConfig[testFiles]=$rootForEnterpriseSuite/$enterpriseSuite/*-suite/*.spec.ts + $CLEANUP && $SETUP + ;; "") ;; *) @@ -96,3 +119,5 @@ function join () { yarn $CMD --env "$(join env)" \ --config "$(join cypressConfig)" \ $PARAMS + +$CLEANUP diff --git a/e2e/start-and-run-suite b/e2e/start-and-run-suite index 2c5c7d1d996..d29b7775b96 100755 --- a/e2e/start-and-run-suite +++ b/e2e/start-and-run-suite @@ -2,11 +2,21 @@ . scripts/grafana-server/variables +LICENSE_PATH="" + +if [ "$1" = "enterprise" ]; then + if [ "$2" != "dev" ] && [ "$2" != "debug" ]; then + LICENSE_PATH=$2/license.jwt + else + LICENSE_PATH=$3/license.jwt + fi +fi + if [ "$BASE_URL" != "" ]; then echo -e "BASE_URL set, skipping starting server" else # Start it in the background - ./scripts/grafana-server/start-server 2>&1 > scripts/grafana-server/server.log & + ./scripts/grafana-server/start-server $LICENSE_PATH 2>&1 > scripts/grafana-server/server.log & ./scripts/grafana-server/wait-for-grafana fi diff --git a/go.mod b/go.mod index 85d53c9f914..108a98b42c1 100644 --- a/go.mod +++ b/go.mod @@ -59,9 +59,9 @@ require ( github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.5.0 - github.com/grafana/alerting v0.0.0-20230119191910-5ebb70a85264 + github.com/grafana/alerting v0.0.0-20230125210216-facc6b27b9e0 github.com/grafana/cuetsy v0.1.5 - github.com/grafana/grafana-aws-sdk v0.11.0 + github.com/grafana/grafana-aws-sdk v0.12.0 github.com/grafana/grafana-azure-sdk-go v1.5.1 github.com/grafana/grafana-plugin-sdk-go v0.147.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 @@ -126,7 +126,7 @@ require ( gopkg.in/square/go-jose.v2 v2.5.1 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 - xorm.io/builder v0.3.6 + xorm.io/builder v0.3.6 // indirect xorm.io/core v0.7.3 xorm.io/xorm v0.8.2 ) @@ -311,6 +311,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect github.com/gophercloud/gophercloud v0.24.0 // indirect + github.com/grafana/sqlds/v2 v2.3.10 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect github.com/hashicorp/memberlist v0.5.0 // indirect diff --git a/go.sum b/go.sum index 4cd7978beac..7e2c44649b6 100644 --- a/go.sum +++ b/go.sum @@ -1394,8 +1394,8 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20230119191910-5ebb70a85264 h1:ApQsGfr05Yma0dpd1+ib11cJQcpmet+E/Ke/aQkdT1Y= -github.com/grafana/alerting v0.0.0-20230119191910-5ebb70a85264/go.mod h1:NoSLbfmUwE+omWFReFrLtbtOItmvTbuQERJ6XFYp9ME= +github.com/grafana/alerting v0.0.0-20230125210216-facc6b27b9e0 h1:BzkQNnj+eevX30EMqJiUS1w3CPoGc8kp7pDf/ari/4Y= +github.com/grafana/alerting v0.0.0-20230125210216-facc6b27b9e0/go.mod h1:NoSLbfmUwE+omWFReFrLtbtOItmvTbuQERJ6XFYp9ME= github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cuetsy v0.1.5 h1:mnFwAXdbqCsyL8r7kkdUMJ4kOAR26cxIPmrZj7JzTeY= @@ -1404,12 +1404,13 @@ github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f h1:FvvSVEbnGeM2bUivG github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f/go.mod h1:uPG2nyK4CtgNDmWv7qyzYcdI+S90kHHRWvHnBtEMBXM= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5JIhUUrggPcPBhOn+eT8+WsHiebvq7GgA= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/grafana/grafana-aws-sdk v0.11.0 h1:ncPD/UN0wNcKq3kEU90RdvrnK/6R4VW2Lo5dPcGk9t0= -github.com/grafana/grafana-aws-sdk v0.11.0/go.mod h1:5Iw3xY7iXJfNaYHrRHMXa/kaB2lWoyntg71PPLGvSs8= +github.com/grafana/grafana-aws-sdk v0.12.0 h1:eUjFdFZeZE+nyu/RMRz+qFxTBew69ToLBrbRhTbjkfM= +github.com/grafana/grafana-aws-sdk v0.12.0/go.mod h1:rCXLYoMpPqF90U7XqgVJ1HIAopFVF0bB3SXBVEJIm3I= github.com/grafana/grafana-azure-sdk-go v1.5.1 h1:d+wwqvWLYvPeGltxU50LNjvCykyJjoyd5exMYJW4nLM= github.com/grafana/grafana-azure-sdk-go v1.5.1/go.mod h1:OJJuBJ3MOoaq2mqD6xlPsArpL2R5j80TrDqPYr35Zak= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2ud7NNM7LrGPO4x0NFR8qLq68CqI4SmB7I2yRN2w9oE= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= +github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= github.com/grafana/grafana-plugin-sdk-go v0.147.0 h1:VavvJOa/Ubs+wzalzWIl+FQmdaD4vEK8KVYU0a8rf+E= github.com/grafana/grafana-plugin-sdk-go v0.147.0/go.mod h1:NMgO3t2gR5wyLx8bWZ9CTmpDk5Txp4wYFccFLHdYn3Q= @@ -1419,6 +1420,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20230119183635-ec19b0a443b7 github.com/grafana/prometheus-alertmanager v0.25.1-0.20230119183635-ec19b0a443b7/go.mod h1:MnBfDPXJqXmmfPwQlCLvVUdqfnvrAw+hSPtDeaaFwj4= github.com/grafana/saml v0.4.13-0.20230123091136-3b6b1ec6c3cb h1:9PLj02xp4DeLTM2+ZyBMcN1sh0ir8GuF/1xXKyF+yws= github.com/grafana/saml v0.4.13-0.20230123091136-3b6b1ec6c3cb/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA= +github.com/grafana/sqlds/v2 v2.3.10 h1:HWKhE0vR6LoEiE+Is8CSZOgaB//D1yqb2ntkass9Fd4= +github.com/grafana/sqlds/v2 v2.3.10/go.mod h1:c6ibxnxRVGxV/0YkEgvy7QpQH/lyifFyV7K/14xvdIs= github.com/grafana/thema v0.0.0-20230122235053-b4b6714dd1c9 h1:nAdsZkvPYNH6wDPkAi9JaDSIf5i2iVz4+Rqk4AOt6sE= github.com/grafana/thema v0.0.0-20230122235053-b4b6714dd1c9/go.mod h1:5j2nf4xmWhKr+1vyGouML8eJ8xERS5Jw/lhjs0eyz78= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6 h1:I9dh1MXGX0wGyxdV/Sl7+ugnki4Dfsy8lv2s5Yf887o= @@ -1966,6 +1969,7 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= diff --git a/jest.config.js b/jest.config.js index 7db7afdf68c..3bb98ec70d4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,8 @@ // 2. Any wrong timezone handling could be hidden if we use UTC/GMT local time (which would happen in CI). process.env.TZ = 'Pacific/Easter'; +const esModules = ['ol', 'd3', 'd3-color', 'd3-interpolate', 'delaunator', 'internmap', 'robust-predicates'].join('|'); + module.exports = { verbose: false, testEnvironment: 'jsdom', @@ -10,7 +12,7 @@ module.exports = { '^.+\\.(ts|tsx|js|jsx)$': [require.resolve('ts-jest'), { isolatedModules: true }], }, transformIgnorePatterns: [ - 'node_modules/(?!(ol)/)', // <- exclude the open layers library + `/node_modules/(?!${esModules})`, // exclude es modules to prevent TS complaining ], moduleDirectories: ['public'], roots: ['/public/app', '/public/test', '/packages'], diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 74a478b786d..2aa725ad809 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -30,11 +30,12 @@ lineage: seqs: [ tags?: [...string] @grafanamaturity(NeedsExpertReview) // Theme of dashboard. style: "light" | *"dark" @grafanamaturity(NeedsExpertReview) - // Timezone of dashboard, - timezone?: *"browser" | "utc" | "" @grafanamaturity(NeedsExpertReview) + // Timezone of dashboard. Accepts IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" // Whether a dashboard is editable or not. - editable: bool | *true - graphTooltip: #DashboardCursorSync @grafanamaturity(NeedsExpertReview) + editable: bool | *true + // Configuration of dashboard cursor sync behavior. + graphTooltip: #DashboardCursorSync // Time range for dashboard, e.g. last 6 hours, last 7 days, etc time?: { from: string | *"now-6h" @@ -55,15 +56,15 @@ lineage: seqs: [ // TODO docs time_options: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] } @grafanamaturity(NeedsExpertReview) - // TODO docs - fiscalYearStartMonth?: uint8 & <13 @grafanamaturity(NeedsExpertReview) + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth?: uint8 & <12 | *0 // TODO docs liveNow?: bool @grafanamaturity(NeedsExpertReview) // TODO docs weekStart?: string @grafanamaturity(NeedsExpertReview) - // TODO docs - refresh?: string | false @grafanamaturity(NeedsExpertReview) + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + refresh?: string | false // Version of the JSON schema, incremented each time a Grafana update brings // changes to said schema. // TODO this is the existing schema numbering system. It will be replaced by Thema's themaVersion @@ -291,7 +292,7 @@ lineage: seqs: [ // 0 for no shared crosshair or tooltip (default). // 1 for shared crosshair. // 2 for shared crosshair AND shared tooltip. - #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") @grafanamaturity(NeedsExpertReview) + #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") // Schema for panel targets is specified by datasource // plugins. We use a placeholder definition, which the Go diff --git a/kinds/preferences/preferences_kind.cue b/kinds/preferences/preferences_kind.cue new file mode 100644 index 00000000000..1e4be8b9b82 --- /dev/null +++ b/kinds/preferences/preferences_kind.cue @@ -0,0 +1,36 @@ +package kind + +name: "Preferences" +maturity: "merged" + +lineage: seqs: [ + { + schemas: [ + { + // UID for the home dashboard + homeDashboardUID?: string + + // The timezone selection + // TODO: this should use the timezone defined in common + timezone?: string + + // day of the week (sunday, monday, etc) + weekStart?: string + + // light, dark, empty is default + theme?: string + + // Selected language (beta) + language?: string + + // Explore query history preferences + queryHistory?: #QueryHistoryPreference + + #QueryHistoryPreference: { + // one of: '' | 'query' | 'starred'; + homeTab?: string + } @cuetsy(kind="interface") //0.0 + }, + ] + }, +] diff --git a/kinds/publicdashboard/public_dashboard_kind.cue b/kinds/publicdashboard/public_dashboard_kind.cue new file mode 100644 index 00000000000..1e960fa7cb4 --- /dev/null +++ b/kinds/publicdashboard/public_dashboard_kind.cue @@ -0,0 +1,26 @@ +package kind + +name: "PublicDashboard" +maturity: "merged" + +lineage: seqs: [ + { + schemas: [ + // 0.0 + { + // Unique public dashboard identifier + uid: string + // Dashboard unique identifier referenced by this public dashboard + dashboardUid: string + // Unique public access token + accessToken?: string + // Flag that indicates if the public dashboard is enabled + isEnabled: bool + // Flag that indicates if annotations are enabled + annotationsEnabled: bool + // Flag that indicates if the time range picker is enabled + timeSelectionEnabled: bool + }, + ] + }, +] diff --git a/kinds/serviceaccount/serviceaccount_kind.cue b/kinds/serviceaccount/serviceaccount_kind.cue new file mode 100644 index 00000000000..afa0f818394 --- /dev/null +++ b/kinds/serviceaccount/serviceaccount_kind.cue @@ -0,0 +1,46 @@ +package kind + +name: "ServiceAccount" +maturity: "merged" + +lineage: seqs: [ + { + schemas: [ + // v0.0 + { + // ID is the unique identifier of the service account in the database. + id: int64 @grafanamaturity(ToMetadata="sys") + // OrgId is the ID of an organisation the service account belongs to. + orgId: int64 @grafanamaturity(ToMetadata="sys") + // Name of the service account. + name: string + // Login of the service account. + login: string + // IsDisabled indicates if the service account is disabled. + isDisabled: bool + // Role is the Grafana organization role of the service account which can be 'Viewer', 'Editor', 'Admin'. + role: #OrgRole @grafanamaturity(ToMetadata="kind") + // Tokens is the number of active tokens for the service account. + // Tokens are used to authenticate the service account against Grafana. + tokens: int64 @grafanamaturity(ToMetadata="kind") + // AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front + // of the service account. + avatarUrl: string @grafanamaturity(ToMetadata="kind") + // AccessControl metadata associated with a given resource. + accessControl?: { + [string]: bool @grafanamaturity(ToMetadata="sys") + } + + // Teams is a list of teams the service account belongs to. + teams?: [...string] @grafanamaturity(ToMetadata="sys") + // Created indicates when the service account was created. + created?: int64 @grafanamaturity(ToMetadata="sys") + // Updated indicates when the service account was updated. + updated?: int64 @grafanamaturity(ToMetadata="sys") + + // OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'. + #OrgRole: "Admin" | "Editor" | "Viewer" @cuetsy(kind="type") + }, + ] + }, +] diff --git a/latest.json b/latest.json index f617f9d7f46..57614a6e872 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.3.1", + "stable": "9.3.6", "testing": "9.3.0" } diff --git a/package.json b/package.json index 99c2f4ef579..75bb1dbfeeb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "e2e:debug": "./e2e/start-and-run-suite debug", "e2e:dev": "./e2e/start-and-run-suite dev", "e2e:benchmark:live": "./e2e/start-and-run-suite benchmark live", + "e2e:enterprise": "./e2e/start-and-run-suite enterprise", + "e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev", + "e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug", "test": "jest --notify --watch", "test:coverage": "jest --coverage", "test:coverage:changes": "jest --coverage --changedSince=origin/main", @@ -23,9 +26,9 @@ "test:ci": "yarn i18n:compile && mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", "lint:fix": "yarn lint:ts --fix", "packages:build": "lerna run build --ignore @grafana-plugins/input-datasource", - "packages:clean": "lerna run clean --parallel", + "packages:clean": "rimraf ./npm-artifacts && lerna run clean --parallel", "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", - "packages:pack": "lerna exec --no-private -- yarn pack", + "packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"", "packages:publish": "lerna exec --no-private -- npm publish package.tgz", "packages:publishCanary": "lerna exec --no-private -- npm publish package.tgz --tag canary", "packages:publishLatest": "lerna exec --no-private -- npm publish package.tgz", @@ -43,7 +46,7 @@ "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", - "themes:generate": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "themes:generate": "esbuild ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "find plugins-bundled -name package.json -not -path '*/node_modules/*' -execdir yarn build \\;", "watch": "yarn start -d watch,start core:start --watchTheme", @@ -54,7 +57,8 @@ "i18n:compile": "echo 'no i18n compile yet, all good'", "betterer": "betterer", "betterer:merge": "betterer merge", - "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts" + "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts", + "betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts" }, "grafana": { "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v9-2/", @@ -111,7 +115,7 @@ "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@swc/core": "1.3.11", "@swc/helpers": "0.4.12", - "@testing-library/dom": "8.19.0", + "@testing-library/dom": "8.20.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", @@ -120,9 +124,10 @@ "@types/angular-route": "1.7.2", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.0", - "@types/d3-force": "^2.1.0", - "@types/d3-scale-chromatic": "1.3.1", + "@types/d3-force": "^3.0.0", + "@types/d3-scale-chromatic": "3.0.0", "@types/debounce-promise": "3.1.5", + "@types/dompurify": "^2", "@types/eslint": "8.4.9", "@types/file-saver": "2.0.5", "@types/glob": "^8.0.0", @@ -163,6 +168,7 @@ "@types/testing-library__jest-dom": "5.14.5", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", + "@types/yargs": "17.0.12", "@typescript-eslint/eslint-plugin": "5.42.0", "@typescript-eslint/parser": "5.42.0", "autoprefixer": "10.4.13", @@ -172,6 +178,8 @@ "babel-plugin-macros": "3.1.0", "blob-polyfill": "7.0.20220408", "browserslist": "^4.21.4", + "chance": "^1.0.10", + "codeowners": "^5.1.1", "copy-webpack-plugin": "9.0.1", "css-loader": "6.7.1", "css-minimizer-webpack-plugin": "4.2.2", @@ -179,10 +187,10 @@ "esbuild": "0.16.17", "esbuild-loader": "2.21.0", "esbuild-plugin-browserslist": "^0.6.0", - "eslint": "8.31.0", + "eslint": "8.32.0", "eslint-config-prettier": "8.6.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "27.1.3", + "eslint-plugin-jest": "27.2.1", "eslint-plugin-jsdoc": "39.6.2", "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-lodash": "7.4.0", @@ -190,9 +198,9 @@ "eslint-plugin-react-hooks": "4.6.0", "eslint-webpack-plugin": "3.2.0", "expose-loader": "4.0.0", - "fork-ts-checker-webpack-plugin": "7.2.13", + "fork-ts-checker-webpack-plugin": "7.3.0", "fs-extra": "10.1.0", - "glob": "8.0.3", + "glob": "8.1.0", "html-loader": "4.2.0", "html-webpack-plugin": "5.5.0", "http-server": "14.1.1", @@ -243,7 +251,8 @@ "webpack-cli": "4.10.0", "webpack-dev-server": "4.11.1", "webpack-manifest-plugin": "5.0.0", - "webpack-merge": "5.8.0" + "webpack-merge": "5.8.0", + "yargs": "^17.5.1" }, "dependencies": { "@daybrush/utils": "1.10.0", @@ -262,7 +271,6 @@ "@grafana/scenes": "latest", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", - "@jaegertracing/jaeger-ui-components": "workspace:*", "@kusto/monaco-kusto": "5.3.6", "@leeoniya/ufuzzy": "0.9.1", "@lezer/common": "1.0.1", @@ -292,8 +300,8 @@ "@visx/event": "2.17.0", "@visx/gradient": "2.10.0", "@visx/group": "2.10.0", - "@visx/scale": "2.2.2", - "@visx/shape": "2.12.2", + "@visx/scale": "3.0.0", + "@visx/shape": "3.0.0", "@visx/tooltip": "2.16.0", "@welldone-software/why-did-you-render": "7.0.1", "angular": "1.8.3", @@ -307,15 +315,17 @@ "calculate-size": "1.1.1", "centrifuge": "3.1.0", "classnames": "2.3.2", + "combokeys": "^3.0.0", "comlink": "4.3.1", "common-tags": "1.8.2", "core-js": "3.27.1", - "d3": "5.15.0", - "d3-force": "^2.1.1", - "d3-scale-chromatic": "1.5.0", + "d3": "7.8.2", + "d3-force": "3.0.0", + "d3-scale-chromatic": "3.0.0", "dangerously-set-html-content": "1.0.9", "date-fns": "2.29.3", "debounce-promise": "3.1.2", + "dompurify": "^2.4.1", "emotion": "11.0.0", "eventemitter3": "4.0.7", "fast-deep-equal": "^3.1.3", @@ -326,15 +336,17 @@ "hoist-non-react-statics": "3.3.2", "i18next": "^22.0.0", "immer": "9.0.16", - "immutable": "4.1.0", + "immutable": "4.2.2", "jquery": "3.6.1", "js-yaml": "^4.1.0", + "json-markup": "^1.1.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", "kbar": "0.1.0-beta.36", "lodash": "4.17.21", "logfmt": "^1.3.2", "lru-cache": "7.14.0", + "lru-memoize": "^1.1.0", "memoize-one": "6.0.0", "moment": "2.29.4", "moment-timezone": "0.5.38", @@ -351,8 +363,8 @@ "prop-types": "15.8.1", "pseudoizer": "^0.1.0", "rc-cascader": "3.8.0", - "rc-drawer": "6.0.1", - "rc-slider": "10.0.1", + "rc-drawer": "6.1.2", + "rc-slider": "10.1.0", "rc-time-picker": "3.7.3", "rc-tree": "5.7.0", "re-resizable": "6.9.9", @@ -379,6 +391,7 @@ "react-table": "7.8.0", "react-transition-group": "4.4.5", "react-use": "17.4.0", + "react-virtual": "2.8.2", "react-virtualized-auto-sizer": "1.0.7", "react-window": "1.8.8", "react-window-infinite-loader": "1.0.8", @@ -400,7 +413,8 @@ "tether-drop": "https://github.com/torkelo/drop", "tinycolor2": "1.4.2", "tslib": "2.4.1", - "uplot": "1.6.23", + "tween-functions": "^1.2.0", + "uplot": "1.6.24", "uuid": "9.0.0", "vendor": "link:./public/vendor", "visjs-network": "4.25.0", @@ -434,7 +448,7 @@ ] }, "engines": { - "node": ">= 16" + "node": ">= 18" }, "packageManager": "yarn@3.3.0" } diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 8aa90a27e4e..27a713d6c83 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -37,8 +37,8 @@ "dependencies": { "@braintree/sanitize-url": "6.0.1", "@grafana/schema": "9.4.0-pre", - "@types/d3-interpolate": "^1.4.0", - "d3-interpolate": "1.4.0", + "@types/d3-interpolate": "^3.0.0", + "d3-interpolate": "3.0.1", "date-fns": "2.29.3", "eventemitter3": "4.0.7", "fast_array_intersect": "1.1.0", @@ -54,7 +54,7 @@ "rxjs": "7.5.7", "tinycolor2": "1.4.2", "tslib": "2.4.1", - "uplot": "1.6.23", + "uplot": "1.6.24", "xss": "1.0.14" }, "devDependencies": { @@ -62,8 +62,7 @@ "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-json": "5.0.1", "@rollup/plugin-node-resolve": "15.0.1", - "@swc/helpers": "0.4.12", - "@testing-library/dom": "8.19.0", + "@testing-library/dom": "8.20.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index f830ed1271a..3d802346614 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -642,7 +642,7 @@ describe('getLinksSupplier', () => { ); const links = supplier({ valueRowIndex: 0 }); - const encodeURIParams = `{"datasource":"${datasourceUid}","queries":["12345"],"panelsState":{}}`; + const encodeURIParams = `{"datasource":"${datasourceUid}","queries":["12345"]}`; expect(links.length).toBe(1); expect(links[0]).toEqual( expect.objectContaining({ diff --git a/packages/grafana-data/src/text/text.ts b/packages/grafana-data/src/text/text.ts index 5b98ba11a6a..0791641a9dc 100644 --- a/packages/grafana-data/src/text/text.ts +++ b/packages/grafana-data/src/text/text.ts @@ -24,7 +24,7 @@ export function findHighlightChunksInText({ } const cleanNeedle = (needle: string): string => { - return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); + return needle.replace(/[[{(][\w,.\/:;<=>?:*+]+$/, ''); }; /** @@ -35,14 +35,17 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] if (!haystack || !needle) { return []; } + const matches: TextMatch[] = []; const { cleaned, flags } = parseFlags(cleanNeedle(needle)); let regexp: RegExp; + try { regexp = new RegExp(`(?:${cleaned})`, flags); } catch (error) { return matches; } + haystack.replace(regexp, (substring, ...rest) => { if (substring) { const offset = rest[rest.length - 2]; @@ -55,6 +58,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] } return ''; }); + return matches; } diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts index 53fa647eb5a..8fa028285bc 100644 --- a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts @@ -1,3 +1,5 @@ +import { Subscription } from 'rxjs'; + import { toDataFrame, toDataFrameDTO } from '../../dataframe'; import { DataFrame, DataTransformerConfig, FieldDTO, FieldType } from '../../types'; import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; @@ -6,11 +8,73 @@ import { transformDataFrame } from '../transformDataFrame'; import { DataTransformerID } from './ids'; import { LabelsToFieldsMode, LabelsToFieldsOptions, labelsToFieldsTransformer } from './labelsToFields'; +function labelsToFieldTransform(source: DataFrame[]): Promise { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: { + mode: LabelsToFieldsMode.Rows, + }, + }; + + const observable = transformDataFrame([cfg], source); + + return new Promise((resolve, reject) => { + const subscription = new Subscription(); + + subscription.add( + observable.subscribe({ + next: (value) => { + subscription.unsubscribe(); + resolve(JSON.parse(JSON.stringify(value))); + }, + error: (err) => { + subscription.unsubscribe(); + reject(err); + }, + }) + ); + }); +} + describe('Labels as Columns', () => { beforeAll(() => { mockTransformationsRegistry([labelsToFieldsTransformer]); }); + it('transform keep the refId of dataFrames', async () => { + const input = [ + toDataFrame({ + refId: 'the-ref-id-A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'Value', type: FieldType.number, values: [1, 2], labels: { labelA: 'valueA', labelB: 'valueB' } }, + ], + }), + toDataFrame({ + refId: 'the-ref-id-B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'Value', type: FieldType.number, values: [1, 2], labels: { labelA: 'valueA', labelB: 'valueB' } }, + ], + }), + ]; + + const output = await labelsToFieldTransform(input); + + const expectedOutput = [ + { + refId: 'the-ref-id-A', + }, + { + refId: 'the-ref-id-B', + }, + ]; + + for (let i = 0; i < output.length; i++) { + expect(output[i]).toMatchObject(expectedOutput[i]); + } + }); + it('data frame with two labels', async () => { const cfg: DataTransformerConfig = { id: DataTransformerID.labelsToFields, diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts index 92a9742dfce..1bdb30df52c 100644 --- a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts @@ -90,6 +90,7 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo void): React.ReactNode; } export const hasLogsContextSupport = (datasource: unknown): datasource is DataSourceWithLogsContextSupport => { @@ -195,11 +202,23 @@ export enum SupplementaryQueryType { * @internal */ export interface DataSourceWithSupplementaryQueriesSupport { + /** + * Returns an observable that will be used to fetch supplementary data based on the provided + * supplementary query type and original request. + */ getDataProvider( type: SupplementaryQueryType, request: DataQueryRequest ): Observable | undefined; + /** + * Returns supplementary query types that data source supports. + */ getSupportedSupplementaryQueryTypes(): SupplementaryQueryType[]; + /** + * Returns a supplementary query to be used to fetch supplementary data based on the provided type and original query. + * If provided query is not suitable for provided supplementary query type, undefined should be returned. + */ + getSupplementaryQuery(type: SupplementaryQueryType, query: TQuery): TQuery | undefined; } export const hasSupplementaryQuerySupport = ( @@ -214,6 +233,17 @@ export const hasSupplementaryQuerySupport = ( return ( withSupplementaryQueriesSupport.getDataProvider !== undefined && + withSupplementaryQueriesSupport.getSupplementaryQuery !== undefined && withSupplementaryQueriesSupport.getSupportedSupplementaryQueryTypes().includes(type) ); }; + +export const hasLogsContextUiSupport = (datasource: unknown): datasource is DataSourceWithLogsContextSupport => { + if (!datasource) { + return false; + } + + const withLogsSupport = datasource as DataSourceWithLogsContextSupport; + + return withLogsSupport.getLogRowContextUi !== undefined; +}; diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index accaf0aab22..89784452605 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -1,4 +1,4 @@ -import { DataLink, FieldType } from '../types'; +import { DataLink, FieldType, TimeRange } from '../types'; import { ArrayVector } from '../vector'; import { mapInternalLinkToExplore } from './dataLinks'; @@ -19,7 +19,7 @@ describe('mapInternalLinkToExplore', () => { link: dataLink, internalLink: dataLink.internal, scopedVars: {}, - range: {} as any, + range: {} as unknown as TimeRange, field: { name: 'test', type: FieldType.number, @@ -32,9 +32,7 @@ describe('mapInternalLinkToExplore', () => { expect(link).toEqual( expect.objectContaining({ title: 'dsName', - href: `/explore?left=${encodeURIComponent( - '{"datasource":"uid","queries":[{"query":"12344"}],"panelsState":{}}' - )}`, + href: `/explore?left=${encodeURIComponent('{"datasource":"uid","queries":[{"query":"12344"}]}')}`, onClick: undefined, }) ); @@ -62,7 +60,7 @@ describe('mapInternalLinkToExplore', () => { link: dataLink, internalLink: dataLink.internal!, scopedVars: {}, - range: {} as any, + range: {} as unknown as TimeRange, field: { name: 'test', type: FieldType.number, @@ -82,4 +80,56 @@ describe('mapInternalLinkToExplore', () => { }) ); }); + + it('interpolates query correctly', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { + query: '$var $var', + // Should not interpolate keys + $var: 'foo', + nested: { + something: '$var', + }, + num: 1, + arr: ['$var', 'non var'], + }, + }, + }; + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal, + scopedVars: { + var1: { text: '', value: 'val1' }, + }, + range: {} as unknown as TimeRange, + field: { + name: 'test', + type: FieldType.number, + config: {}, + values: new ArrayVector([2]), + }, + replaceVariables: (val, scopedVars) => val.replace(/\$var/g, scopedVars!['var1'].value), + }); + + expect(decodeURIComponent(link.href)).toEqual( + `/explore?left=${JSON.stringify({ + datasource: 'uid', + queries: [ + { + query: 'val1 val1', + $var: 'foo', + nested: { something: 'val1' }, + num: 1, + arr: ['val1', 'non var'], + }, + ], + })}` + ); + }); }); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 4aadad9ac0c..b0b47b0e373 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -88,30 +88,38 @@ function generateInternalHref( ); } -function interpolateObject( - object: T | undefined, +function interpolateObject( + obj: T | undefined, + scopedVars: ScopedVars, + replaceVariables: InterpolateFunction +): T | undefined { + if (!obj) { + return obj; + } + if (typeof obj === 'string') { + // @ts-ignore this is complaining we are returning string, but we are checking if obj is a string so should be fine. + return replaceVariables(obj, scopedVars); + } + const copy = JSON.parse(JSON.stringify(obj)); + return interpolateObjectRecursive(copy, scopedVars, replaceVariables); +} + +function interpolateObjectRecursive( + obj: T, scopedVars: ScopedVars, replaceVariables: InterpolateFunction ): T { - let stringifiedQuery = ''; - try { - stringifiedQuery = JSON.stringify(object || {}); - } catch (err) { - // should not happen and not much to do about this, possibly something non stringifiable in the query - console.error(err); + for (const k of Object.keys(obj)) { + // Honestly not sure how to type this to make TS happy. + // @ts-ignore + if (typeof obj[k] === 'string') { + // @ts-ignore + obj[k] = replaceVariables(obj[k], scopedVars); + // @ts-ignore + } else if (typeof obj[k] === 'object' && obj[k] !== null) { + // @ts-ignore + obj[k] = interpolateObjectRecursive(obj[k], scopedVars, replaceVariables); + } } - - // Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not - // actually work with every datasource query right now. - stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars); - - let replacedQuery = {} as T; - try { - replacedQuery = JSON.parse(stringifiedQuery); - } catch (err) { - // again should not happen and not much to do about this, probably some issue with how we replaced the variables. - console.error(stringifiedQuery, err); - } - - return replacedQuery; + return obj; } diff --git a/packages/grafana-e2e/src/flows/addDataSource.ts b/packages/grafana-e2e/src/flows/addDataSource.ts index c38947bba44..4737d1e726a 100644 --- a/packages/grafana-e2e/src/flows/addDataSource.ts +++ b/packages/grafana-e2e/src/flows/addDataSource.ts @@ -26,7 +26,7 @@ export const addDataSource = (config?: Partial) => { form: () => {}, name: `e2e-${uuidv4()}`, skipTlsVerify: false, - type: 'TestData DB', + type: 'TestData', ...config, }; diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index b39f8343576..41a95599839 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -52,7 +52,7 @@ "@grafana/tsconfig": "^1.2.0-rc1", "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-node-resolve": "15.0.1", - "@testing-library/dom": "8.19.0", + "@testing-library/dom": "8.20.0", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", "@testing-library/user-event": "14.4.3", diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index a9ef4dbf9b3..c6614026f04 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -27,6 +27,7 @@ export interface AzureSettings { export class GrafanaBootConfig implements GrafanaConfig { isPublicDashboardView: boolean; + snapshotEnabled = true; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; auth: AuthSettings = {}; @@ -110,6 +111,7 @@ export class GrafanaBootConfig implements GrafanaConfig { pluginAdminEnabled = true; pluginAdminExternalManageEnabled = false; pluginCatalogHiddenPlugins: string[] = []; + pluginsCDNBaseURL = ''; expressionsEnabled = false; customTheme?: undefined; awsAllowedAuthProviders: string[] = []; diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index ccf6c391f71..1656ca5f07b 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -40,7 +40,6 @@ "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-json": "5.0.1", "@rollup/plugin-node-resolve": "15.0.1", - "@swc/helpers": "0.4.12", "esbuild": "0.16.17", "rimraf": "3.0.2", "rollup": "2.79.1", diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 9557cee3648..b4bf0b99b55 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -51,6 +51,27 @@ export interface DataQuery { refId: string; } +export interface BaseDimensionConfig { + field?: string; + fixed: (string | number); +} + +export interface ScaleDimensionConfig extends BaseDimensionConfig { + max: number; + min: number; +} + +/** + * This is actually an empty interface used mainly for naming? + */ +export interface ColorDimensionConfig extends BaseDimensionConfig {} + +export enum TextDimensionMode { + Field = 'field', + Fixed = 'fixed', + Template = 'template', +} + export interface MapLayerOptions { /** * Custom options depending on the type @@ -464,6 +485,11 @@ export enum BigValueTextMode { */ export type FieldTextAlignment = ('auto' | 'left' | 'right' | 'center'); +/** + * Controls the value alignment in the TimelineChart component + */ +export type TimelineValueAlignment = ('center' | 'left' | 'right'); + /** * TODO docs */ @@ -651,6 +677,10 @@ export interface DataSourceRef { uid?: string; } +export interface TextDimensionConfig extends BaseDimensionConfig { + mode: TextDimensionMode; +} + export interface FrameGeometrySource { /** * Path to Gazetteer diff --git a/packages/grafana-schema/src/common/dimensions.cue b/packages/grafana-schema/src/common/dimensions.cue new file mode 100644 index 00000000000..14899a59c47 --- /dev/null +++ b/packages/grafana-schema/src/common/dimensions.cue @@ -0,0 +1,25 @@ +package common + +BaseDimensionConfig: { + field?: string + fixed: string | number +}@cuetsy(kind="interface") + +ScaleDimensionConfig: { + BaseDimensionConfig + min: int32 + max: int32 +}@cuetsy(kind="interface") + +// This is actually an empty interface used mainly for naming? +ColorDimensionConfig: { + BaseDimensionConfig + _empty: _ +}@cuetsy(kind="interface") + +TextDimensionMode: "fixed" | "field" | "template" @cuetsy(kind="enum") + +TextDimensionConfig: { + BaseDimensionConfig + mode: TextDimensionMode +}@cuetsy(kind="interface") diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index 537c6549570..bdb88a689e5 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -193,6 +193,9 @@ BigValueTextMode: "auto" | "value" | "value_and_name" | "name" | "none" @cuetsy( // TODO docs FieldTextAlignment: "auto" | "left" | "right" | "center" @cuetsy(kind="type") +// Controls the value alignment in the TimelineChart component +TimelineValueAlignment: "center" | "left" | "right" @cuetsy(kind="type") + // TODO docs VizTextDisplayOptions: { // Explicit title text size diff --git a/packages/grafana-schema/src/index.gen.ts b/packages/grafana-schema/src/index.gen.ts index 9fdf476a0e4..a7a76a0c3df 100644 --- a/packages/grafana-schema/src/index.gen.ts +++ b/packages/grafana-schema/src/index.gen.ts @@ -95,6 +95,24 @@ export type { // Raw generated enums and default consts from playlist kind. export { defaultPlaylist } from './raw/playlist/x/playlist_types.gen'; +// Raw generated types from Preferences kind. +export type { + Preferences, + QueryHistoryPreference +} from './raw/preferences/x/preferences_types.gen'; + +// Raw generated types from PublicDashboard kind. +export type { PublicDashboard } from './raw/publicdashboard/x/publicdashboard_types.gen'; + +// Raw generated types from ServiceAccount kind. +export type { + ServiceAccount, + OrgRole +} from './raw/serviceaccount/x/serviceaccount_types.gen'; + +// Raw generated enums and default consts from serviceaccount kind. +export { defaultServiceAccount } from './raw/serviceaccount/x/serviceaccount_types.gen'; + // Raw generated types from Team kind. export type { Team } from './raw/team/x/team_types.gen'; 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 fad06ea56cf..706d6522ba9 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 @@ -647,10 +647,13 @@ export interface Dashboard { */ editable: boolean; /** - * TODO docs + * The month that the fiscal year starts on. 0 = January, 11 = December */ fiscalYearStartMonth?: number; gnetId?: string; + /** + * Configuration of dashboard cursor sync behavior. + */ graphTooltip: DashboardCursorSync; /** * Unique numeric identifier for the dashboard. @@ -667,7 +670,7 @@ export interface Dashboard { liveNow?: boolean; panels?: Array<(Panel | RowPanel | GraphPanel | HeatmapPanel)>; /** - * TODO docs + * Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". */ refresh?: (string | false); /** @@ -774,9 +777,9 @@ export interface Dashboard { time_options: Array; }; /** - * Timezone of dashboard, + * Timezone of dashboard. Accepts IANA TZDB zone ID or "browser" or "utc". */ - timezone?: ('browser' | 'utc' | ''); + timezone?: string; /** * Title of dashboard. */ @@ -797,6 +800,7 @@ export interface Dashboard { export const defaultDashboard: Partial = { editable: true, + fiscalYearStartMonth: 0, graphTooltip: DashboardCursorSync.Off, links: [], panels: [], diff --git a/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts b/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts new file mode 100644 index 00000000000..e18a9375d62 --- /dev/null +++ b/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts @@ -0,0 +1,44 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// TSTypesJenny +// LatestMajorsOrXJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +export interface QueryHistoryPreference { + /** + * one of: '' | 'query' | 'starred'; + */ + homeTab?: string; +} + +export interface Preferences { + /** + * UID for the home dashboard + */ + homeDashboardUID?: string; + /** + * Selected language (beta) + */ + language?: string; + /** + * Explore query history preferences + */ + queryHistory?: QueryHistoryPreference; + /** + * light, dark, empty is default + */ + theme?: string; + /** + * The timezone selection + * TODO: this should use the timezone defined in common + */ + timezone?: string; + /** + * day of the week (sunday, monday, etc) + */ + weekStart?: string; +} diff --git a/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts b/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts new file mode 100644 index 00000000000..b94e4baefe3 --- /dev/null +++ b/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts @@ -0,0 +1,36 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// TSTypesJenny +// LatestMajorsOrXJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +export interface PublicDashboard { + /** + * Unique public access token + */ + accessToken?: string; + /** + * Flag that indicates if annotations are enabled + */ + annotationsEnabled: boolean; + /** + * Dashboard unique identifier referenced by this public dashboard + */ + dashboardUid: string; + /** + * Flag that indicates if the public dashboard is enabled + */ + isEnabled: boolean; + /** + * Flag that indicates if the time range picker is enabled + */ + timeSelectionEnabled: boolean; + /** + * Unique public dashboard identifier + */ + uid: string; +} diff --git a/packages/grafana-schema/src/raw/serviceaccount/x/serviceaccount_types.gen.ts b/packages/grafana-schema/src/raw/serviceaccount/x/serviceaccount_types.gen.ts new file mode 100644 index 00000000000..a06b06ff31f --- /dev/null +++ b/packages/grafana-schema/src/raw/serviceaccount/x/serviceaccount_types.gen.ts @@ -0,0 +1,71 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// TSTypesJenny +// LatestMajorsOrXJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +/** + * OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'. + */ +export type OrgRole = ('Admin' | 'Editor' | 'Viewer'); + +export interface ServiceAccount { + /** + * AccessControl metadata associated with a given resource. + */ + accessControl?: Record; + /** + * AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front + * of the service account. + */ + avatarUrl: string; + /** + * Created indicates when the service account was created. + */ + created?: number; + /** + * ID is the unique identifier of the service account in the database. + */ + id: number; + /** + * IsDisabled indicates if the service account is disabled. + */ + isDisabled: boolean; + /** + * Login of the service account. + */ + login: string; + /** + * Name of the service account. + */ + name: string; + /** + * OrgId is the ID of an organisation the service account belongs to. + */ + orgId: number; + /** + * Role is the Grafana organization role of the service account which can be 'Viewer', 'Editor', 'Admin'. + */ + role: OrgRole; + /** + * Teams is a list of teams the service account belongs to. + */ + teams?: Array; + /** + * Tokens is the number of active tokens for the service account. + * Tokens are used to authenticate the service account against Grafana. + */ + tokens: number; + /** + * Updated indicates when the service account was updated. + */ + updated?: number; +} + +export const defaultServiceAccount: Partial = { + teams: [], +}; diff --git a/packages/grafana-schema/src/veneer/common.types.ts b/packages/grafana-schema/src/veneer/common.types.ts index 1e35686cbbe..5ae07a1e2c1 100644 --- a/packages/grafana-schema/src/veneer/common.types.ts +++ b/packages/grafana-schema/src/veneer/common.types.ts @@ -12,5 +12,14 @@ export interface DataQuery extends raw.DataQuery { // TODO remove explicit nulls datasource?: raw.DataSourceRef | null; } +export interface BaseDimensionConfig extends Omit { + fixed: T; +} + +export interface ScaleDimensionConfig extends BaseDimensionConfig, Omit {} + +export interface TextDimensionConfig extends BaseDimensionConfig, Omit {} + +export interface ColorDimensionConfig extends BaseDimensionConfig, Omit {} export * from '../common/common.gen'; diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile deleted file mode 100644 index 3907fe9c05a..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM alpine:3.15.6 - -USER root - -COPY scripts scripts -COPY install /usr/local - -WORKDIR scripts - -RUN ./deploy.sh diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md deleted file mode 100644 index 94b42aefd3d..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Using this docker image - -Uploaded to dockerhub as grafana/grafana-plugin-ci:latest-alpine - -Based off of `circleci/node:12-browsers` - -## User - -The user will be `circleci` -The home directory will be `/home/circleci` - -## Node - -- node 12 is installed -- yarn is installed globally -- npm is installed globally - -## Go - -- Go is installed in `/usr/local/bin/go` -- golangci-lint is installed in `/usr/local/bin/golangci-lint` -- mage is installed in `/home/circleci/go/bin/mage` - -All of the above directories are in the path, so there is no need to specify fully qualified paths. - -## Grafana - -- Installed in `/home/circleci/src/grafana` -- `yarn install` has been run - -## Integration/Release Testing - -There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: - -1. 6.6.2 -2. 6.5.3 -3. 6.4.5 -4. 6.3.7 - -To test, your CircleCI config will need a run section with something similar to the following - -``` -- run: - name: Setup Grafana (local install) - command: | - sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb - sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini - sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini - sudo service grafana-server start - grafana-cli --version -``` - -# Building - -To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine` - -``` -./build.sh -``` - -# Developing/Testing - -To test, you should have docker-compose installed. - -``` -cd test -./start.sh -``` - -You will be in /home/circleci/test with the buildscripts installed to the local directory. -Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh deleted file mode 100755 index f024e617055..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -set -eo pipefail - -source ./common.sh - -# -# No longer required, but useful to keep just in case we want to deploy -# changes in toolkit directly to the docker image -# -if [ -n "$INCLUDE_TOOLKIT" ]; then - /bin/rm -rfv install/grafana-toolkit - mkdir -pv install/grafana-toolkit - cp -rv ../../bin install/grafana-toolkit - cp -rv ../../src install/grafana-toolkit - cp -v ../../package.json install/grafana-toolkit - cp -v ../../tsconfig.json install/grafana-toolkit -fi - -docker build -t ${DOCKER_IMAGE_NAME} . -docker push $DOCKER_IMAGE_NAME - -[ -n "$INCLUDE_TOOLKIT" ] && /bin/rm -rfv install/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh deleted file mode 100755 index 8a99e1e649b..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -## -## Common variable declarations -## Find the latest tags on https://hub.docker.com/r/grafana/grafana-plugin-ci/tags?page=1&name=alpine -## - -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.1-alpine" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp deleted file mode 100755 index fd23c3db871..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -if [ "$1" == "-rn" ]; then - false | busybox cp -i -r "$2" "$3" 2>/dev/null -else - busybox cp $* -fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall deleted file mode 100755 index 537305a1f72..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/sh -## -# gget -# A script to get and install grafana versions -# for usage information see "show_help" below. -# - -latest=$(wget -O - 'https://raw.githubusercontent.com/grafana/grafana/main/latest.json' | jq -r '.stable') -canary=$(wget -O - "https://grafana.com/api/grafana/versions" | jq ".items[0].version" | tr -d '"') - -show_help() { - echo "Usage: gget " - echo "" - echo "where can be:" - echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" - echo " 2) latest (currently $latest)" - echo " 3) canary (currently $canary)" - echo "" - echo " -h, --help: Display this help message" - echo "" - exit 0 -} - -opts=$(getopt -o h --long help -n 'gget' -- "$@") -[ $? -eq 0 ] || { - show_help -} - -eval set -- "$opts" -while true; do - case "$1" in - -h | --help) - show_help - ;; - --) - shift - break - ;; - *) - break - ;; - esac - shift -done - -[ -z "$1" ] && show_help - -# Make sure the script is being run as root -if [ $EUID -ne 0 ]; then - echo "This script must be run as root" - exit 1 -fi - -## -# MAIN -# -# Enough setup, let's actually do something -# -version=$1 -if [ "$version" == "latest" ]; then - version="$latest" - wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -elif [ "$version" == "canary" ]; then - version="$canary" - wget -O - "https://dl.grafana.com/oss/main/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -else - wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -fi - -/bin/rm -rf /opt/grafana > /dev/null 2>&1 || true -ln -s /opt/grafana-${version} /opt/grafana - -# nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js deleted file mode 100644 index 849836da380..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js +++ /dev/null @@ -1,154 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -const fs = require('fs'); -const path = require('path'); -const tslib_1 = require('tslib'); - -const getPluginId_1 = require('../../config/utils/getPluginId'); -const pluginValidation_1 = require('../../config/utils/pluginValidation'); -const env_1 = require('../../plugins/env'); -// @ts-ignore -// import execa = require('execa'); -const githubClient_1 = tslib_1.__importDefault(require('./githubClient')); -const resolveContentType = function (extension) { - if (extension.startsWith('.')) { - extension = extension.slice(1); - } - switch (extension) { - case 'zip': - return 'application/zip'; - case 'json': - return 'application/json'; - case 'sha1': - return 'text/plain'; - default: - return 'application/octet-stream'; - } -}; -const GitHubRelease = /** @class */ (function () { - function GitHubRelease(token, username, repository, releaseNotes, commitHash) { - this.token = token; - this.username = username; - this.repository = repository; - this.releaseNotes = releaseNotes; - this.commitHash = commitHash; - this.git = new githubClient_1.default({ - required: true, - repo: repository, - }); - } - GitHubRelease.prototype.publishAssets = function (srcLocation, destUrl) { - const _this = this; - // Add the assets. Loop through files in the ci/dist folder and upload each asset. - const files = fs.readdirSync(srcLocation); - return files.map(function (file) { - return tslib_1.__awaiter(_this, void 0, void 0, function () { - let fileStat, fileData; - return tslib_1.__generator(this, function (_a) { - fileStat = fs.statSync(srcLocation + '/' + file); - fileData = fs.readFileSync(srcLocation + '/' + file); - return [ - 2 /*return*/, - this.git.client.post(destUrl + '?name=' + file, fileData, { - headers: { - 'Content-Type': resolveContentType(path.extname(file)), - 'Content-Length': fileStat.size, - }, - maxContentLength: fileStat.size * 2 * 1024 * 1024, - }), - ]; - }); - }); - }); - }; - GitHubRelease.prototype.release = function () { - let _a, _b, _c, _d; - return tslib_1.__awaiter(this, void 0, void 0, function () { - let ciDir, - distDir, - distContentDir, - pluginJsonFile, - pluginInfo, - PUBLISH_DIR, - commitHash, - latestRelease, - reason_1, - newReleaseResponse, - publishPromises, - reason_2; - return tslib_1.__generator(this, function (_e) { - switch (_e.label) { - case 0: - ciDir = env_1.getCiFolder(); - distDir = path.resolve(ciDir, 'dist'); - distContentDir = path.resolve(distDir, getPluginId_1.getPluginId()); - pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); - pluginInfo = pluginValidation_1.getPluginJson(pluginJsonFile).info; - PUBLISH_DIR = path.resolve(env_1.getCiFolder(), 'packages'); - commitHash = this.commitHash || ((_a = pluginInfo.build) === null || _a === void 0 ? void 0 : _a.hash); - _e.label = 1; - case 1: - _e.trys.push([1, 5, , 6]); - return [4 /*yield*/, this.git.client.get('releases/tags/v' + pluginInfo.version)]; - case 2: - latestRelease = _e.sent(); - if (!(latestRelease.data.tag_name === 'v' + pluginInfo.version)) { - return [3 /*break*/, 4]; - } - return [4 /*yield*/, this.git.client.delete('releases/' + latestRelease.data.id)]; - case 3: - _e.sent(); - _e.label = 4; - case 4: - return [3 /*break*/, 6]; - case 5: - reason_1 = _e.sent(); - if (reason_1.response.status !== 404) { - // 404 just means no release found. Not an error. Anything else though, re throw the error - throw reason_1; - } - return [3 /*break*/, 6]; - case 6: - _e.trys.push([6, 9, , 10]); - return [ - 4 /*yield*/, - this.git.client.post('releases', { - tag_name: 'v' + pluginInfo.version, - target_commitish: commitHash, - name: 'v' + pluginInfo.version, - body: this.releaseNotes, - draft: false, - prerelease: false, - }), - ]; - case 7: - newReleaseResponse = _e.sent(); - publishPromises = this.publishAssets( - PUBLISH_DIR, - 'https://uploads.github.com/repos/' + - this.username + - '/' + - this.repository + - '/releases/' + - newReleaseResponse.data.id + - '/assets' - ); - return [4 /*yield*/, Promise.all(publishPromises)]; - case 8: - _e.sent(); - return [3 /*break*/, 10]; - case 9: - reason_2 = _e.sent(); - console.log(reason_2); - // Rethrow the error so that we can trigger a non-zero exit code to circle-ci - throw reason_2; - case 10: - return [2 /*return*/]; - } - }); - }); - }; - return GitHubRelease; -})(); -exports.GitHubRelease = GitHubRelease; -//# sourceMappingURL=githubRelease.js.map7027e10521e9 diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh deleted file mode 100755 index 9263691386f..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh - -## -# Script to deploy a docker image. Must return exit code 0 -# -do_exit() { - message="$1" - exit_code="$2" - - echo "$message" - exit $exit_code -} - - -## -# Get file, get's a file, validates the SHA -# @param filename -# @param expected sha value -# @returns 0 if successful, -1 of checksum validation failed. -# -get_file () { - [ -n "$1" ] && url=$1 || do_exit "url required" 1 - [ -n "$2" ] && dest=$2 || do_exit "destination required" 2 - sha=$3 - file=$(basename $dest) - - curl -fL "${url}" -o "$dest" - if [ -n "$sha" ]; then - echo "$sha $dest" | sha256sum || do_exit "Checksum validation failed for $file. Exiting" 1 - fi -} - -untar_file () { - [ -n "$1" ] && src=$1 || do_exit "src required" 1 - [ -n "$2" ] && dest=$2 || dest="/usr/local" - - tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" -} - -## -# WIP: Just started this and not finished. -# The intent it to download a release from a git repo, -# compile, and install -get_latest_release () { - tarsrc=$(curl -sL "https://api.github.com/repos/$1/$2/releases/latest" | jq ".tarball_url" | tr -d '"') - curl -fL -o /tmp/autoretrieved.tar.gz "$tarsrc" - origdir=$PWD - reponame=$(tar zxvf autoretrieved.tar.gz | tail -1 | awk -F / '{print $1}') - cd "/tmp/$reponame" - #perform compile - cd $origdir -} diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh deleted file mode 100755 index f8926ec4678..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -source "./deploy-common.sh" - diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh deleted file mode 100755 index 94345950078..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/sh -set -eo pipefail - -source "./deploy-common.sh" - -# Make libgcc compatible -mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 - -# Replace cp with something that mocks the one that ci-package needs -rm /bin/cp -mv /usr/local/bin/cp /bin/cp - -apk add --no-cache curl npm yarn build-base openssh git-lfs perl-utils coreutils python3 - -# -# Only relevant for testing, but cypress does not work with musl/alpine. -# -# apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat - -# Install Go -filename="go1.19.4.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" -untar_file "/tmp/$filename" - -# Install golangci-lint -GOLANGCILINT_VERSION=1.50.0 -filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" -get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ - "/tmp/$filename.tar.gz" \ - "b4b329efcd913082c87d0e9606711ecb57415b5e6ddf233fde9e76c69d9b4e8b" -untar_file "/tmp/$filename.tar.gz" -ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint -ln -s /usr/local/go/bin/go /usr/local/bin/go -ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt -chmod 755 /usr/local/bin/golangci-lint - -# Install dependencies -apk add --no-cache fontconfig zip jq - -# Install code climate -get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ - "/usr/local/bin/cc-test-reporter" \ - "20d1d4e2b399d0287d91e65faeee8ffbef08e3262b0be5eda7def7b3c2799ddd" -chmod 755 /usr/local/bin/cc-test-reporter - -curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl" - -apk add --no-cache git -# Install Mage -mkdir -pv /tmp/mage $HOME/go/bin -git clone https://github.com/magefile/mage.git /tmp/mage -cd /tmp/mage && go run bootstrap.go -mv $HOME/go/bin/mage /usr/local/bin - -wget -O - -q https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b /usr/local/bin v2.14.0 - -source "/etc/profile" -sh -l -c "go get -u github.com/mgechev/revive" -for file in $(ls $HOME/go/bin); do - mv -v $HOME/go/bin/$file /usr/local/bin/$file -done - -# Install grafana-toolkit deps -current_dir=$PWD -cd /usr/local/grafana-toolkit && yarn install && cd $current_dir -ln -s /usr/local/grafana-toolkit/bin/grafana-toolkit.js /usr/local/bin/grafana-toolkit - -GOOGLE_SDK_VERSION=365.0.1 -GOOGLE_SDK_CHECKSUM=17003cdba67a868c2518ac16efa60dc6175533b7a9fb87304459784308e30fb0 - -curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status -tar xvzf google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -C /opt -rm google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -ln -s /opt/google-cloud-sdk/bin/gsutil /usr/bin/gsutil -ln -s /opt/google-cloud-sdk/bin/gcloud /usr/bin/gcloud - -# Cleanup after yourself -/bin/rm -rf /tmp/mage -/bin/rm -rf $HOME/go diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml deleted file mode 100644 index 9a3e53f2cc3..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: '3' -services: - citest: - image: "amd64/alpine" - user: root - volumes: - - ../scripts:/home/circleci/scripts - - ../install:/home/circleci/install - - ${HOME}/.ssh:/root/.ssh - - ../../..:/home/circleci/grafana-toolkit - cibuilt: - image: "grafana/grafana-plugin-ci:latest-alpine" - user: root - volumes: - - ../scripts:/home/circleci/scripts - - ../install:/home/circleci/install - - ${HOME}/.ssh:/root/.ssh - - ../../..:/home/circleci/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh deleted file mode 100755 index f5551eb3b6e..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -function finish { - echo "Exiting and cleaning up docker image" - docker-compose down -} -trap finish EXIT - -# Enter the docker container -if [ "$1" = "built" ]; then - docker-compose run cibuilt sh -c "cd /home/circleci; exec sh --login -i" -else - docker-compose run citest sh -c "cd /home/circleci; exec sh --login -i" -fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile deleted file mode 100644 index 6aa2682c27b..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM debian:buster-slim - -ENV DEBIAN_FRONTEND=noninteractive - -COPY scripts scripts -COPY install /usr/local - -RUN cd scripts && ./deploy.sh -ENV DEBIAN_FRONTEND=newt diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md deleted file mode 100644 index 1b37557a0b3..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Using this docker image - -## User - -The user will be `circleci` -The home directory will be `/home/circleci` - -## Node - -- node 14 is installed -- yarn is installed globally -- npm is installed globally - -## Go - -- Go is installed in `/usr/local/bin/go` -- golangci-lint is installed in `/usr/local/bin/golangci-lint` -- mage is installed in `/usr/local/bin/mage` - -All of the above directories are in the path, so there is no need to specify fully qualified paths. - -## Grafana - -- Installed in `/home/circleci/src/grafana` -- `yarn install` has been run - -## Integration/Release Testing - -There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: - -1. 6.6.2 -2. 6.5.3 -3. 6.4.5 -4. 6.3.7 - -To test, your CircleCI config will need a run section with something similar to the following - -``` -- run: - name: Setup Grafana (local install) - command: | - sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb - sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini - sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini - sudo service grafana-server start - grafana-cli --version -``` - -# Building - -To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e` - -``` -./build.sh -``` - -# Developing/Testing - -To test, you should have docker-compose installed. - -``` -cd test -./start.sh -``` - -You will be in /home/circleci/test with the buildscripts installed to the local directory. -Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh deleted file mode 100755 index 14ca02a1dc9..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -set -eo pipefail - -source ./common.sh - -# -# No longer required, but useful to keep just in case we want to deploy -# changes in toolkit directly to the docker image -# -if [ -n "$INCLUDE_TOOLKIT" ]; then - /bin/rm -rfv install/grafana-toolkit - mkdir -pv install/grafana-toolkit - cp -rv ../../bin install/grafana-toolkit - cp -rv ../../src install/grafana-toolkit - cp -v ../../package.json install/grafana-toolkit - cp -v ../../tsconfig.json install/grafana-toolkit -fi - -docker build -t ${DOCKER_IMAGE_NAME} . -docker push $DOCKER_IMAGE_NAME -docker tag ${DOCKER_IMAGE_NAME} ${DOCKER_IMAGE_BASE_NAME}:latest -docker push ${DOCKER_IMAGE_BASE_NAME}:latest - -[ -n "$INCLUDE_TOOLKIT" ] && /bin/rm -rfv install/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh deleted file mode 100755 index 29714cd5a84..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -## -## Common variable declarations -## Find the latest tag on https://hub.docker.com/r/grafana/grafana-plugin-ci-e2e/tags -## - -DOCKER_IMAGE_BASE_NAME="grafana/grafana-plugin-ci-e2e" -DOCKER_IMAGE_VERSION="1.6.1" -DOCKER_IMAGE_NAME="${DOCKER_IMAGE_BASE_NAME}:${DOCKER_IMAGE_VERSION}" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall deleted file mode 100755 index a71019bdbef..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -## -# gget -# A script to get and install grafana versions -# for usage information see "show_help" below. -# - -latest=$(wget -O - 'https://raw.githubusercontent.com/grafana/grafana/main/latest.json' | jq -r '.stable') -canary=$(wget -O - "https://grafana.com/api/grafana-enterprise/versions" | jq ".items[0].version" | tr -d '"') - -show_help() { - echo "Usage: gget " - echo "" - echo "where can be:" - echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" - echo " 2) latest (currently $latest)" - echo " 3) canary (currently $canary)" - echo "" - echo " -h, --help: Display this help message" - echo "" - exit 0 -} - -opts=$(getopt -o h --long help -n 'gget' -- "$@") -[ $? -eq 0 ] || { - show_help -} - -eval set -- "$opts" -while true; do - case "$1" in - -h | --help) - show_help - ;; - --) - shift - break - ;; - *) - break - ;; - esac - shift -done - -[ -z "$1" ] && show_help - -# Make sure the script is being run as root -if [ $EUID -ne 0 ]; then - echo "This script must be run as root" - exit 1 -fi - -## -# MAIN -# -# Enough setup, let's actually do something -# -version=$1 -if [ "$version" == "latest" ]; then - version="$latest" - wget -O - "https://dl.grafana.com/enterprise/release/grafana-enterprise-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -elif [ "$version" == "canary" ]; then - version="$canary" - wget -O - "https://dl.grafana.com/enterprise/main/grafana-enterprise-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -else - wget -O - "https://dl.grafana.com/enterprise/release/grafana-enterprise-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - -fi - -/bin/rm -rf /opt/grafana > /dev/null 2>&1 || true -ln -s /opt/grafana-${version} /opt/grafana - -# nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh deleted file mode 100755 index af585d2eb19..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -## -# Script to deploy a docker image. Must return exit code 0 -# -do_exit() { - message="$1" - exit_code="$2" - - echo "$message" - exit $exit_code -} - -## -# Get file, get's a file, validates the SHA -# @param filename -# @param expected sha value -# @returns 0 if successful, -1 of checksum validation failed. -# -get_file () { - [ -n "$1" ] && url=$1 || do_exit "url required" -1 - [ -n "$2" ] && dest=$2 || do_exit "destination required" -2 - sha=$3 - file=$(basename $dest) - - wget "$url" -O "$dest" - if [ -n "$sha" ]; then - echo "$sha $dest" | sha256sum --check --status || do_exit "Checksum validation failed for $file. Exiting" -1 - fi -} - -untar_file () { - [ -n "$1" ] && src=$1 || do_exit "src required" -1 - [ -n "$2" ] && dest=$2 || dest="/usr/local" - - tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" -} diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh deleted file mode 100755 index 9c3c0a31e2d..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -source "/etc/profile" - -apt-get --allow-insecure-repositories update -apt-get install --allow-unauthenticated -y \ - build-essential \ - wget git sudo adduser \ - libfontconfig1 \ - locate \ - libnss3 libnspr4 \ - libgdk-pixbuf2.0-0 \ - libgtk-3-0 \ - libpangocairo-1.0-0 \ - libpango-1.0-0 \ - libatk1.0-0 \ - libcairo2 \ - libdbus-1-3 \ - libxcomposite1 libxrender1 libxcursor1 libxi6 libxtst6 libxrandr2 libxss1 xauth xvfb \ - libasound2 \ - libatk-bridge2.0-0 \ - libatspi2.0-0 \ - libcups2 \ - jq net-tools git-lfs unzip pkg-config zip \ - libaio1 libaio-dev \ - netcat \ - libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb - diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh deleted file mode 100755 index 08f2b7b950a..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -source "./deploy-common.sh" - diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh deleted file mode 100755 index 99e711aee87..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash -set -eo pipefail - -source "/etc/profile" -source "./deploy-slim.sh" -source "./deploy-common.sh" - -NODEVER="v16.13.2" -# Install Node -wget -O - "https://nodejs.org/dist/${NODEVER}/node-${NODEVER}-linux-x64.tar.xz" | tar Jvxf - -C "/tmp" - -# Move node to /usr/local so it's in the path -pushd /tmp/node-${NODEVER}-linux-x64 -/bin/rm -f CHANGELOG.md README.md LICENSE -/bin/cp -r * /usr/local -popd -/bin/rm -rf /tmp/node-${NODEVER} - -# Resource the profile so we know our path is being honoured -source "/etc/profile" -# Install Yarn. Not in the path yet so fully qualified -npm i -g yarn - -# Install Go -filename="go1.19.4.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" -untar_file "/tmp/$filename" - -# Install golangci-lint -GOLANGCILINT_VERSION=1.50.0 -filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" -get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ - "/tmp/$filename.tar.gz" \ - "b4b329efcd913082c87d0e9606711ecb57415b5e6ddf233fde9e76c69d9b4e8b" -untar_file "/tmp/$filename.tar.gz" -ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint -ln -s /usr/local/go/bin/go /usr/local/bin/go -ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt -chmod 755 /usr/local/bin/golangci-lint - -# Install code climate -get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ - "/usr/local/bin/cc-test-reporter" \ - "20d1d4e2b399d0287d91e65faeee8ffbef08e3262b0be5eda7def7b3c2799ddd" -chmod 755 /usr/local/bin/cc-test-reporter - -wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl" -chmod +x /usr/local/bin/grabpl - -# Install Mage -mkdir -pv /tmp/mage $HOME/go/bin -git clone https://github.com/magefile/mage.git /tmp/mage -pushd /tmp/mage && go run bootstrap.go && popd -mv $HOME/go/bin/mage /usr/local/bin -# Cleanup after yourself -/bin/rm -rf /tmp/mage -/bin/rm -rf $HOME/go - -# add cypress -yarn global add cypress -# verify cypress install -cypress verify - -# Get the size down -/bin/rm -rf /var/lib/apt/lists diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml deleted file mode 100644 index 5d6297c4833..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: '3' -services: - citest: - image: "debian:buster-slim" - user: root - volumes: - - ../scripts:/root/scripts - - ../install:/root/install - - ${HOME}/.ssh:/root/.ssh - - ../../..:/root/grafana-toolkit - cibuilt: - image: "grafana/grafana-plugin-ci-e2e" - user: root - volumes: - - ../scripts:/root/scripts - - ../install:/root/install - - ${HOME}/.ssh:/root/.ssh - - ../../..:/root/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh deleted file mode 100755 index 2552c964a85..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -function finish { - echo "Exiting and cleaning up docker image" - docker-compose down -} -trap finish EXIT - -# Enter the docker container -if [ "$1" = "built" ]; then - docker-compose run cibuilt sh -c "cd /root; exec bash --login -i" -else - docker-compose run citest sh -c "cd /root; exec bash --login -i" -fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile deleted file mode 100644 index 46228193273..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM debian:testing-20210111-slim -USER root -COPY scripts scripts -WORKDIR scripts -RUN apt-get update && \ - apt-get install -y wget && \ - ./deploy.sh -COPY install/gget /usr/local/bin/gget diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md deleted file mode 100644 index 18bc1aec592..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Using this docker image - -Currently tagged and uploaded to dockerhub as srclosson/integrations-ci-build - -Based off of `circleci/node:12-browsers` - -## User - -The user will be `circleci` -The home directory will be `/home/circleci` - -## Node - -- node 12 is installed -- yarn is installed globally -- npm is installed globally - -## Go - -- Go is installed in `/usr/local/bin/go` -- golangci-lint is installed in `/usr/local/bin/golangci-lint` -- mage is installed in `/home/circleci/go/bin/mage` - -All of the above directories are in the path, so there is no need to specify fully qualified paths. - -## Grafana - -- Installed in `/home/circleci/src/grafana` -- `yarn install` has been run - -## Integration/Release Testing - -There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: - -1. 6.6.2 -2. 6.5.3 -3. 6.4.5 -4. 6.3.7 - -To test, your CircleCI config will need a run section with something similar to the following - -``` -- run: - name: Setup Grafana (local install) - command: | - sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb - sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini - sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini - sudo service grafana-server start - grafana-cli --version -``` - -# Building - -To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci` - -``` -./build.sh -``` - -# Developing/Testing - -To test, you should have docker-compose installed. - -``` -cd test -./start.sh -``` - -You will be in /home/circleci/test with the buildscripts installed to the local directory. -Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh deleted file mode 100755 index 3d8d3d7110a..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -eo pipefail - -source ./common.sh - -docker build -t ${DOCKER_IMAGE_NAME} . -docker push $DOCKER_IMAGE_NAME - diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh deleted file mode 100755 index 47d2b068d8d..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -## -## Common variable declarations -## Find the latest tags on https://hub.docker.com/r/grafana/grafana-plugin-ci/tags -## - -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.1" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget b/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget deleted file mode 100755 index 3e38e2d5911..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -## -# gget -# A script to get and install grafana versions -# for usage information see "show_help" below. -# - -latest=$(curl -s 'https://raw.githubusercontent.com/grafana/grafana/main/latest.json' | jq -r '.stable') -canary=$(curl -s "https://grafana.com/api/grafana/versions" | jq ".items[0].version" | tr -d '"') - -show_help() { - echo "Usage: gget " - echo "" - echo "where can be:" - echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" - echo " 2) latest (currently $latest)" - echo " 3) canary (currently $canary)" - echo "" - echo " -h, --help: Display this help message" - echo "" - exit 0 -} - -opts=$(getopt -o h --long help -n 'gget' -- "$@") -[ $? -eq 0 ] || { - show_help -} - -eval set -- "$opts" -while true; do - case "$1" in - -h | --help) - show_help - ;; - --) - shift - break - ;; - *) - break - ;; - esac - shift -done - -[ -z "$1" ] && show_help - -# Make sure the script is being run as root -if [ $EUID -ne 0 ]; then - echo "This script must be run as root" - exit 1 -fi - -## -# MAIN -# -# Enough setup, let's actually do something -# -version=$1 -[ "$version" == "latest" ] && version="$latest" -[ "$version" == "canary" ] && version="$canary" -wget "https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb" -O "/tmp/grafana_${version}_amd64.deb" -dpkg -i "/tmp/grafana_${version}_amd64.deb" && /bin/rm -rfv "/tmp/grafana_${version}_amd64.deb" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh deleted file mode 100755 index 524bb5e4ec6..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -## -# Script to deploy a docker image. Must return exit code 0 -# -do_exit() { - message="$1" - exit_code="$2" - - echo "$message" - exit $exit_code -} - - -## -# Get file, get's a file, validates the SHA -# @param filename -# @param expected sha value -# @returns 0 if successful, -1 of checksum validation failed. -# -get_file () { - [ -n "$1" ] && url=$1 || do_exit "url required" -1 - [ -n "$2" ] && dest=$2 || do_exit "destination required" -2 - sha=$3 - file=$(basename $dest) - - wget "$url" -O "$dest" - if [ -n "$sha" ]; then - echo "$sha $dest" | sha256sum --check --status || do_exit "Checksum validation failed for $file. Exiting" -1 - fi -} - -untar_file () { - [ -n "$1" ] && src=$1 || do_exit "src required" -1 - [ -n "$2" ] && dest=$2 || dest="/usr/local" - - tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" -} \ No newline at end of file diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh deleted file mode 100755 index 08f2b7b950a..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -source "./deploy-common.sh" - diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh deleted file mode 100755 index 3ee0bff1e16..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -source "./deploy-common.sh" - -# Install Go -filename="go1.19.4.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" -untar_file "/tmp/$filename" - -# Install golangci-lint -GOLANGCILINT_VERSION=1.50.0 -filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" -get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ - "/tmp/$filename.tar.gz" \ - "b4b329efcd913082c87d0e9606711ecb57415b5e6ddf233fde9e76c69d9b4e8b" -untar_file "/tmp/$filename.tar.gz" -ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint -ln -s /usr/local/go/bin/go /usr/local/bin/go -ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt -chmod 755 /usr/local/bin/golangci-lint - -# Install dependencies -apt-get update -y && apt-get install -y adduser libfontconfig1 locate && /bin/rm -rf /var/lib/apt/lists/* - -# Install code climate -get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ - "/usr/local/bin/cc-test-reporter" \ - "20d1d4e2b399d0287d91e65faeee8ffbef08e3262b0be5eda7def7b3c2799ddd" -chmod 755 /usr/local/bin/cc-test-reporter - -wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl" -chmod +x /usr/local/bin/grabpl - -# Install Mage -mkdir -pv /tmp/mage $HOME/go/bin -git clone https://github.com/magefile/mage.git /tmp/mage -pushd /tmp/mage && go run bootstrap.go && popd -mv $HOME/go/bin/mage /usr/local/bin - -GOOGLE_SDK_VERSION=365.0.1 -GOOGLE_SDK_CHECKSUM=17003cdba67a868c2518ac16efa60dc6175533b7a9fb87304459784308e30fb0 - -curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status -tar xvzf google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -C /opt -rm google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -ln -s /opt/google-cloud-sdk/bin/gsutil /usr/bin/gsutil -ln -s /opt/google-cloud-sdk/bin/gcloud /usr/bin/gcloud - -# Cleanup after yourself -/bin/rm -rf /tmp/mage -/bin/rm -rf $HOME/go - -# Perform user specific initialization -sudo -u circleci ./deploy-user.sh - -# Get the size down -/bin/rm -rf /var/lib/apt/lists diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml deleted file mode 100644 index d734b3a38e6..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: '3' -services: - citest: - image: "circleci/node:12-browsers" - user: root - volumes: - - ../scripts:/home/circleci/scripts - - ../install:/home/circleci/install - - ${HOME}/.ssh:/root/.ssh - - ../../..:/home/circleci/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh deleted file mode 100755 index af3d91cb4a0..00000000000 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -function finish { - echo "Exiting and cleaning up docker image" - docker-compose down -} -trap finish EXIT - -# Enter the docker container -docker-compose run citest bash -c "cd /home/circleci; exec bash --login -i" diff --git a/packages/grafana-ui/.storybook/main.ts b/packages/grafana-ui/.storybook/main.ts index ad5a021197e..e737d0126c5 100644 --- a/packages/grafana-ui/.storybook/main.ts +++ b/packages/grafana-ui/.storybook/main.ts @@ -1,6 +1,7 @@ import path from 'path'; import type { StorybookConfig } from '@storybook/react/types'; -import { getAvailableIcons, IconName } from '../src/types/icon'; +// avoid importing from @grafana/data to prevent node error: ERR_REQUIRE_ESM +import { availableIconsIndex, IconName } from '../../grafana-data/src/types/icon'; import { getIconSubDir } from '../src/components/Icon/utils'; const stories = ['../src/**/*.story.@(tsx|mdx)']; @@ -11,7 +12,7 @@ if (process.env.NODE_ENV !== 'production') { // We limit icon paths to only the available icons so publishing // doesn't require uploading 1000s of unused assets. -const iconPaths = getAvailableIcons() +const iconPaths = Object.keys(availableIconsIndex) .filter((iconName) => !iconName.includes('fa')) .map((iconName) => { const subDir = getIconSubDir(iconName as IconName, 'default'); diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index dc219b691c4..73d828f5be1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -67,11 +67,11 @@ "calculate-size": "1.1.1", "classnames": "2.3.2", "core-js": "3.27.1", - "d3": "5.15.0", + "d3": "7.8.2", "date-fns": "2.29.3", "hoist-non-react-statics": "3.3.2", "i18next": "^22.0.0", - "immutable": "4.1.0", + "immutable": "4.2.2", "is-hotkey": "0.2.0", "jquery": "3.6.1", "lodash": "4.17.21", @@ -81,10 +81,10 @@ "ol": "7.1.0", "prismjs": "1.29.0", "rc-cascader": "3.8.0", - "rc-drawer": "6.0.1", - "rc-slider": "10.0.1", + "rc-drawer": "6.1.2", + "rc-slider": "10.1.0", "rc-time-picker": "^3.7.3", - "rc-tooltip": "5.2.2", + "rc-tooltip": "5.3.1", "react-beautiful-dnd": "13.1.1", "react-calendar": "3.9.0", "react-colorful": "5.6.1", @@ -109,7 +109,7 @@ "slate-react": "0.22.10", "tinycolor2": "1.4.2", "tslib": "2.4.1", - "uplot": "1.6.23", + "uplot": "1.6.24", "uuid": "9.0.0" }, "devDependencies": { @@ -133,8 +133,7 @@ "@storybook/preset-scss": "1.0.3", "@storybook/react": "6.5.14", "@storybook/theming": "6.5.14", - "@swc/helpers": "0.4.12", - "@testing-library/dom": "8.19.0", + "@testing-library/dom": "8.20.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index 71b0f25ccc1..2b5953b7638 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -30,7 +30,7 @@ export const colorPickerFactory = ( popover: React.ComponentType>, displayName = 'ColorPicker' ) => { - return class ColorPicker extends Component { + return class ColorPicker extends Component { static displayName = displayName; pickerTriggerRef = createRef(); diff --git a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx new file mode 100644 index 00000000000..79e244a99b3 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; + +import { InlineSwitch } from '../../components/Switch/Switch'; +import { InlineField } from '../Forms/InlineField'; + +export interface Props + extends Pick, 'options' | 'onOptionsChange'> {} + +export interface SecureSocksProxyConfig extends DataSourceJsonData { + enableSecureSocksProxy?: boolean; +} + +export function SecureSocksProxySettings({ + options, + onOptionsChange, +}: Props): JSX.Element { + return ( + <> +

Secure Socks Proxy

+
+
+
+ + + onOptionsChange({ + ...options, + jsonData: { ...options.jsonData, enableSecureSocksProxy: event!.currentTarget.checked }, + }) + } + /> + +
+
+
+ + ); +} diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index b714d3dfb5d..01bdef10bec 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -36,6 +36,12 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) => setShow(false); }; + const handleKeys = (event: React.KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Tab') { + setShow(false); + } + }; + return ( <> {React.cloneElement(typeof children === 'function' ? children(visible) : children, { @@ -43,13 +49,13 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) => })} {visible && ( - + {/* this is handling bubbled events from the inner overlay see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
+
{ expect(screen.getByText('Accepted file type: .json')).toBeInTheDocument(); }); + it('should show an error message when the file size exceeds the max file size', async () => { + render(); + + dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData(files)); + + expect(await screen.findByText('File is larger than 1 B')).toBeInTheDocument(); + }); + it('should show the accepted file type(s) when passed in as a array of strings', () => { render(); diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx index 81961749565..76f3ed99f74 100644 --- a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; import { isString, uniqueId } from 'lodash'; import React, { ReactNode, useCallback, useState } from 'react'; -import { Accept, DropEvent, DropzoneOptions, FileRejection, useDropzone } from 'react-dropzone'; +import { Accept, DropEvent, DropzoneOptions, FileError, FileRejection, useDropzone, ErrorCode } from 'react-dropzone'; -import { GrafanaTheme2 } from '@grafana/data'; +import { formattedValueToString, getValueFormat, GrafanaTheme2 } from '@grafana/data'; import { useTheme2 } from '../../themes'; import { Alert } from '../Alert/Alert'; @@ -59,7 +59,7 @@ export interface DropzoneFile { export function FileDropzone({ options, children, readAs, onLoad, fileListRenderer, onFileRemove }: FileDropzoneProps) { const [files, setFiles] = useState([]); - const [errorMessages, setErrorMessages] = useState([]); + const [fileErrors, setErrorMessages] = useState([]); const setFileProperty = useCallback( (customFile: DropzoneFile, action: (customFileToModify: DropzoneFile) => void) => { @@ -166,11 +166,15 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender }); const setErrors = (rejectedFiles: FileRejection[]) => { - let errors: string[] = []; + let errors: FileError[] = []; rejectedFiles.map((rejectedFile) => { - rejectedFile.errors.map((error) => { - if (errors.indexOf(error.message) === -1) { - errors.push(error.message); + rejectedFile.errors.map((newError) => { + if ( + errors.findIndex((presentError) => { + return presentError.code === newError.code && presentError.message === newError.message; + }) === -1 + ) { + errors.push(newError); } }); }); @@ -178,12 +182,22 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender setErrorMessages(errors); }; - const getErrorMessages = () => { + const renderErrorMessages = (errors: FileError[]) => { return (
- {errorMessages.map((error) => { - return
{error}
; + {errors.map((error) => { + switch (error.code) { + case ErrorCode.FileTooLarge: + const formattedSize = getValueFormat('decbytes')(options?.maxSize!); + return ( +
+ File is larger than {formattedValueToString(formattedSize)} +
+ ); + default: + return
{error.message}
; + } })}
@@ -200,7 +214,7 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender {children ?? }
- {errorMessages.length > 0 && getErrorMessages()} + {fileErrors.length > 0 && renderErrorMessages(fileErrors)} {options?.accept && ( {getAcceptedFileTypeText(options.accept)} )} @@ -252,6 +266,7 @@ export function FileDropzoneDefaultChildren({
); } + function getPrimaryText(files: DropzoneFile[], options?: BackwardsCompatibleDropzoneOptions) { if (options?.multiple === undefined || options?.multiple) { return 'Upload file'; diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index c601c17adc1..a411c06a6a1 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -57,9 +57,13 @@ export const Icon = React.forwardRef( console.warn('Icon component passed an invalid icon name', name); } + if (!name || name.includes('..')) { + return
invalid icon name
; + } + const svgSize = getSvgSize(size); const svgHgt = svgSize; - const svgWid = name?.startsWith('gf-bar-align') ? 16 : name?.startsWith('gf-interp') ? 30 : svgSize; + const svgWid = name.startsWith('gf-bar-align') ? 16 : name.startsWith('gf-interp') ? 30 : svgSize; const subDir = getIconSubDir(name, type); const svgPath = `${iconRoot}${subDir}/${name}.svg`; diff --git a/packages/grafana-ui/src/components/Menu/hooks.ts b/packages/grafana-ui/src/components/Menu/hooks.ts index 3ca3d2baf5b..f4983e80ff5 100644 --- a/packages/grafana-ui/src/components/Menu/hooks.ts +++ b/packages/grafana-ui/src/components/Menu/hooks.ts @@ -98,11 +98,10 @@ export const useMenuFocus = ({ menuItems?.[focusedItem]?.click(); break; case 'Escape': - event.preventDefault(); - event.stopPropagation(); onClose?.(); break; case 'Tab': + event.preventDefault(); onClose?.(); break; default: diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx index 2d2169e4812..68eaeebc3f9 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -1,6 +1,8 @@ import { screen, render } from '@testing-library/react'; import React from 'react'; +import { LoadingState } from '@grafana/data'; + import { PanelChrome, PanelChromeProps } from './PanelChrome'; const setup = (propOverrides?: Partial) => { @@ -35,18 +37,50 @@ it('renders an empty panel with padding', () => { expect(screen.getByText("Panel's Content").parentElement).not.toHaveStyle({ padding: '0px' }); }); -it('renders panel with a header if prop title', () => { +// Check for backwards compatibility +it('renders panel 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', () => { +// Check for backwards compatibility +it('renders panel with title in place if prop title', () => { setup({ title: 'Test Panel Header' }); expect(screen.getByText('Test Panel Header')).toBeInTheDocument(); }); +// Check for backwards compatibility +it('renders panel with a header if prop leftItems', () => { + setup({ + leftItems: [
This should be a self-contained node
], + }); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +// todo implement when hoverHeader is implemented +it.skip('renders panel without header if no title, no leftItems, and hoverHeader is undefined', () => { + setup(); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +// todo implement when hoverHeader is implemented +it.skip('renders panel with a fixed header if prop hoverHeader is false', () => { + setup({ hoverHeader: false }); + + expect(screen.getByTestId('header-container')).toBeInTheDocument(); +}); + +// todo implement when hoverHeader is implemented +it.skip('renders panel with a hovering header if prop hoverHeader is true', () => { + setup({ title: 'Test Panel Header', hoverHeader: true }); + + expect(screen.queryByTestId('header-container')).not.toBeInTheDocument(); +}); + it('renders panel with a header if prop titleItems', () => { setup({ titleItems: [
This should be a self-contained node
], @@ -63,11 +97,6 @@ it('renders panel with a header with icons in place if prop titleItems', () => { expect(screen.getByTestId('title-items-container')).toBeInTheDocument(); }); -it.skip('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
}); @@ -81,8 +110,38 @@ it('renders panel with a show-on-hover menu icon if prop menu', () => { expect(screen.getByTestId('panel-menu-button')).not.toBeVisible(); }); -it.skip('renders states in the panel header if any given', () => {}); +it('renders error status in the panel header if any given', () => { + setup({ statusMessage: 'Error test' }); -it.skip('renders leftItems in the panel header if any given when no states prop is given', () => {}); + expect(screen.getByLabelText('Panel status')).toBeInTheDocument(); +}); -it.skip('renders states in the panel header if both leftItems and states are given', () => {}); +it('does not render error status in the panel header if loadingState is error, but no statusMessage', () => { + setup({ loadingState: LoadingState.Error, statusMessage: '' }); + + expect(screen.queryByTestId('panel-status')).not.toBeInTheDocument(); +}); + +it('renders loading indicator in the panel header if loadingState is loading', () => { + setup({ loadingState: LoadingState.Loading }); + + expect(screen.getByLabelText('Panel loading bar')).toBeInTheDocument(); +}); + +it('renders loading indicator in the panel header if loadingState is loading regardless of not having a header', () => { + setup({ loadingState: LoadingState.Loading, hoverHeader: true }); + + expect(screen.getByLabelText('Panel loading bar')).toBeInTheDocument(); +}); + +it('renders loading indicator in the panel header if loadingState is loading regardless of having a header', () => { + setup({ loadingState: LoadingState.Loading, hoverHeader: false }); + + expect(screen.getByLabelText('Panel loading bar')).toBeInTheDocument(); +}); + +it('renders streaming indicator in the panel header if loadingState is streaming', () => { + setup({ loadingState: LoadingState.Streaming }); + + expect(screen.getByTestId('panel-streaming')).toBeInTheDocument(); +}); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 9f91c0cc315..5ea484bbf2b 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -26,10 +26,13 @@ export interface PanelChromeProps { description?: string | (() => string); titleItems?: ReactNode[]; menu?: ReactElement | (() => ReactElement); - /** dragClass, hoverHeader not yet implemented */ dragClass?: string; dragClassCancel?: string; hoverHeader?: boolean; + /** + * Use only to indicate loading or streaming data in the panel. + * Any other values of loadingState are ignored. + */ loadingState?: LoadingState; /** * Used to display status message (used for panel errors currently) @@ -39,11 +42,13 @@ export interface PanelChromeProps { * Handle opening error details view (like inspect / error tab) */ statusMessageOnClick?: (e: React.SyntheticEvent) => void; - /** @deprecated in favor of props - * status for errors and loadingState for loading and streaming + /** + * @deprecated in favor of props + * statusMessage for error messages + * and loadingState for loading and streaming data * which will serve the same purpose - * of showing/interacting with the panel's data state - * */ + * of showing/interacting with the panel's state + */ leftItems?: ReactNode[]; } @@ -64,14 +69,13 @@ export function PanelChrome({ description = '', titleItems = [], menu, - // dragClass, + dragClass, + dragClassCancel, hoverHeader = false, loadingState, statusMessage, statusMessageOnClick, leftItems, - dragClass, - dragClassCancel, }: PanelChromeProps) { const theme = useTheme2(); const styles = useStyles2(getStyles); @@ -108,7 +112,9 @@ export function PanelChrome({ return (
- {loadingState === LoadingState.Loading ? : null} + {loadingState === LoadingState.Loading ? ( + + ) : null}
@@ -127,7 +133,7 @@ export function PanelChrome({ )} {loadingState === LoadingState.Streaming && ( -
+
@@ -156,6 +162,7 @@ export function PanelChrome({ className={cx(styles.errorContainer, dragClassCancel)} message={statusMessage} onClick={statusMessageOnClick} + ariaLabel="Panel status" /> )}
@@ -229,6 +236,7 @@ const getStyles = (theme: GrafanaTheme2) => { }, }), loadingBarContainer: css({ + label: 'panel-loading-bar-container', position: 'absolute', top: 0, width: '100%', @@ -246,6 +254,7 @@ const getStyles = (theme: GrafanaTheme2) => { padding: theme.spacing(0, 0, 0, 1), }), streaming: css({ + label: 'panel-streaming', marginRight: 0, color: theme.colors.success.text, @@ -254,6 +263,7 @@ const getStyles = (theme: GrafanaTheme2) => { }, }), title: css({ + label: 'panel-title', marginBottom: 0, // override default h6 margin-bottom textOverflow: 'ellipsis', overflow: 'hidden', @@ -270,6 +280,7 @@ const getStyles = (theme: GrafanaTheme2) => { alignItems: 'center', }), menuItem: css({ + label: 'panel-menu', visibility: 'hidden', border: 'none', }), diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx index 3a61ca6ec7b..15bbc4a3a08 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx @@ -10,9 +10,10 @@ export interface Props { className?: string; message?: string; onClick?: (e: React.SyntheticEvent) => void; + ariaLabel?: string; } -export function PanelStatus({ className, message, onClick }: Props) { +export function PanelStatus({ className, message, onClick, ariaLabel = 'status' }: Props) { const styles = useStyles2(getStyles); return ( @@ -22,6 +23,7 @@ export function PanelStatus({ className, message, onClick }: Props) { variant={'destructive'} icon="exclamation-triangle" tooltip={message || ''} + aria-label={ariaLabel} /> ); } diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.test.ts b/packages/grafana-ui/src/components/TimeSeries/utils.test.ts index 6b08d8e2196..f1c74c96858 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.test.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.test.ts @@ -1,11 +1,10 @@ -import { ArrayVector, FieldType } from '@grafana/data'; +import { ArrayVector, EventBus, FieldType } from '@grafana/data'; import { getTheme } from '@grafana/ui'; import { preparePlotConfigBuilder } from './utils'; describe('when fill below to option is used', () => { - // eslint-disable-next-line - let eventBus: any; + let eventBus: EventBus; // eslint-disable-next-line let renderers: any[]; // eslint-disable-next-line diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 154e11ad566..368c4beb04f 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -184,7 +184,9 @@ export { } from './ErrorBoundary/ErrorBoundary'; export { ErrorWithStack } from './ErrorBoundary/ErrorWithStack'; export { DataSourceHttpSettings } from './DataSourceSettings/DataSourceHttpSettings'; +export { CustomHeadersSettings } from './DataSourceSettings/CustomHeadersSettings'; export { AlertingSettings } from './DataSourceSettings/AlertingSettings'; +export { SecureSocksProxySettings } from './DataSourceSettings/SecureSocksProxySettings'; export { TLSAuthSettings } from './DataSourceSettings/TLSAuthSettings'; export { CertificationKey } from './DataSourceSettings/CertificationKey'; export { Spinner } from './Spinner/Spinner'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index cbdfdbd4539..2011ad0d147 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -116,7 +116,7 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { minMax = uPlot.rangeNum(hardMinOnly ? hardMin : dataMin, hardMaxOnly ? hardMax : dataMax, rangeConfig); } } else if (scale.distr === 3) { - minMax = uPlot.rangeLog(dataMin!, dataMax!, logBase, true); + minMax = uPlot.rangeLog(hardMin ?? dataMin!, hardMax ?? dataMax!, logBase, true); } if (decimals === 0) { @@ -154,13 +154,15 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { } } - // if all we got were hard limits, treat them as static min/max - if (hardMinOnly) { - minMax[0] = hardMin!; - } + if (scale.distr === 1) { + // if all we got were hard limits, treat them as static min/max + if (hardMinOnly) { + minMax[0] = hardMin!; + } - if (hardMaxOnly) { - minMax[1] = hardMax!; + if (hardMaxOnly) { + minMax[1] = hardMax!; + } } // guard against invalid y ranges diff --git a/packages/grafana-ui/src/slate-plugins/braces.ts b/packages/grafana-ui/src/slate-plugins/braces.ts index 7af64918c11..d05360fffa6 100644 --- a/packages/grafana-ui/src/slate-plugins/braces.ts +++ b/packages/grafana-ui/src/slate-plugins/braces.ts @@ -2,7 +2,7 @@ import { Annotation } from 'slate'; import { Plugin } from 'slate-react'; import { v4 as uuidv4 } from 'uuid'; -const BRACES: any = { +const BRACES: Record = { '[': ']', '{': '}', '(': ')', diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index 9692546a057..1a531bc1db6 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -13,6 +13,7 @@ "ts-node": { "compilerOptions": { "isolatedModules": false, + "resolveJsonModule": true, "module": "commonjs" } } diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json deleted file mode 100644 index 8984f23da5d..00000000000 --- a/packages/jaeger-ui-components/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@jaegertracing/jaeger-ui-components", - "version": "9.4.0-pre", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "private": true, - "scripts": { - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "@grafana/tsconfig": "^1.2.0-rc1", - "@testing-library/jest-dom": "5.16.5", - "@testing-library/react": "12.1.4", - "@testing-library/user-event": "14.4.3", - "@types/deep-freeze": "^0.1.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/jest": "29.2.3", - "@types/lodash": "4.14.187", - "@types/prop-types": "15.7.5", - "@types/react": "17.0.42", - "@types/react-icons": "2.2.7", - "@types/sinon": "^10.0.13", - "@types/slate-react": "0.22.9", - "@types/testing-library__jest-dom": "5.14.5", - "@types/tinycolor2": "1.4.3", - "sinon": "14.0.1", - "typescript": "4.8.4" - }, - "dependencies": { - "@emotion/css": "11.10.5", - "@grafana/data": "9.4.0-pre", - "@grafana/e2e-selectors": "9.4.0-pre", - "@grafana/runtime": "9.4.0-pre", - "@grafana/ui": "9.4.0-pre", - "chance": "^1.0.10", - "classnames": "^2.2.5", - "combokeys": "^3.0.0", - "copy-to-clipboard": "^3.1.0", - "deep-freeze": "^0.0.1", - "fuzzy": "^0.1.3", - "hoist-non-react-statics": "^3.3.2", - "json-markup": "^1.1.0", - "lodash": "4.17.21", - "lru-memoize": "^1.1.0", - "memoize-one": "6.0.0", - "moment": "2.29.4", - "moment-timezone": "0.5.38", - "prop-types": "15.8.1", - "react": "17.0.2", - "react-dom": "17.0.2", - "react-icons": "2.2.7", - "reselect": "4.1.6", - "tinycolor2": "1.4.2", - "tslib": "2.4.1", - "tween-functions": "^1.2.0" - } -} diff --git a/packages/jaeger-ui-components/src/common/NewWindowIcon.test.tsx b/packages/jaeger-ui-components/src/common/NewWindowIcon.test.tsx deleted file mode 100644 index bde47016e78..00000000000 --- a/packages/jaeger-ui-components/src/common/NewWindowIcon.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// 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 } from '@testing-library/react'; -import React from 'react'; - -import NewWindowIcon, { getStyles } from './NewWindowIcon'; - -describe('NewWindowIcon', () => { - it('adds is-large className when props.isLarge is true', () => { - const { container } = render(); - const styles = getStyles(); - expect(container.firstChild).toHaveClass(styles.NewWindowIconLarge); - }); -}); diff --git a/packages/jaeger-ui-components/src/constants/default-config.tsx b/packages/jaeger-ui-components/src/constants/default-config.tsx deleted file mode 100644 index 5dfe3420920..00000000000 --- a/packages/jaeger-ui-components/src/constants/default-config.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2017 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 deepFreeze from 'deep-freeze'; - -import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index'; - -export default deepFreeze( - Object.defineProperty( - { - archiveEnabled: false, - dependencies: { - dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES, - menuEnabled: true, - }, - linkPatterns: [], - menu: [ - { - label: 'About Jaeger', - items: [ - { - label: 'GitHub', - url: 'https://github.com/uber/jaeger', - }, - { - label: 'Docs', - url: 'http://jaeger.readthedocs.io/en/latest/', - }, - { - label: 'Twitter', - url: 'https://twitter.com/JaegerTracing', - }, - { - label: 'Discussion Group', - url: 'https://groups.google.com/forum/#!forum/jaeger-tracing', - }, - { - label: 'Gitter.im', - url: 'https://gitter.im/jaegertracing/Lobby', - }, - { - label: 'Blog', - url: 'https://medium.com/jaegertracing/', - }, - ], - }, - ], - search: { - maxLookback: { - label: '2 Days', - value: '2d', - }, - maxLimit: 1500, - }, - tracking: { - gaID: null, - trackErrors: true, - }, - }, - // fields that should be individually merged vs wholesale replaced - '__mergeFields', - { value: ['dependencies', 'search', 'tracking'] } - ) -); - -export const deprecations = [ - { - formerKey: 'dependenciesMenuEnabled', - currentKey: 'dependencies.menuEnabled', - }, - { - formerKey: 'gaTrackingID', - currentKey: 'tracking.gaID', - }, -]; diff --git a/packages/jaeger-ui-components/src/types/TDdgState.tsx b/packages/jaeger-ui-components/src/types/TDdgState.tsx deleted file mode 100644 index f1aba58ac7c..00000000000 --- a/packages/jaeger-ui-components/src/types/TDdgState.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// 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 { fetchedState } from '../constants'; -import { TDdgModel } from '../model/ddg/types'; - -import { ApiError } from './api-error'; - -export type TDdgStateEntry = - | { - state: typeof fetchedState.LOADING; - } - | { - error: ApiError; - state: typeof fetchedState.ERROR; - } - | { - model: TDdgModel; - state: typeof fetchedState.DONE; - viewModifiers: Map; - }; - -type TDdgState = Record; - -// eslint-disable-next-line no-undef -export default TDdgState; diff --git a/packages/jaeger-ui-components/src/types/embedded.tsx b/packages/jaeger-ui-components/src/types/embedded.tsx deleted file mode 100644 index e7f87776df5..00000000000 --- a/packages/jaeger-ui-components/src/types/embedded.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2018 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. - -type EmbeddedStateV0 = { - version: 'v0'; - searchHideGraph: boolean; - timeline: { - collapseTitle: boolean; - hideMinimap: boolean; - hideSummary: boolean; - }; -}; - -export type EmbeddedState = EmbeddedStateV0; diff --git a/packages/jaeger-ui-components/src/types/search.tsx b/packages/jaeger-ui-components/src/types/search.tsx deleted file mode 100644 index 380d4316ddc..00000000000 --- a/packages/jaeger-ui-components/src/types/search.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2017 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 { TNil } from '.'; - -export type SearchQuery = { - end: number | string; - limit: number | string; - lookback: string; - maxDuration: null | string; - minDuration: null | string; - operation: string | TNil; - service: string; - start: number | string; - tags: string | TNil; -}; - -/** - * Type used to summarize traces for the search page. - */ -export type TraceSummary = { - /** - * Duration of trace in milliseconds. - */ - duration: number; - /** - * Start time of trace in milliseconds. - */ - timestamp: number; - traceName: string; - traceID: string; - numberOfErredSpans: number; - numberOfSpans: number; - services: Array<{ name: string; numberOfSpans: number }>; -}; - -export type TraceSummaries = { - /** - * Duration of longest trace in `traces` in milliseconds. - */ - maxDuration: number; - traces: TraceSummary[]; -}; diff --git a/packages/jaeger-ui-components/tsconfig.json b/packages/jaeger-ui-components/tsconfig.json deleted file mode 100644 index 83f4d88d032..00000000000 --- a/packages/jaeger-ui-components/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "allowJs": true, - "jsx": "react" - }, - "exclude": ["dist", "node_modules"], - "extends": "@grafana/tsconfig", - "include": [ - "src/**/*.ts*", - "typings", - "../../public/app/types/jquery/*.ts", - "../../public/app/types/*.d.ts", - "../grafana-ui/src/types/*.d.ts" - ] -} diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 035cf4ac315..55945b28a4d 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -3,9 +3,9 @@ package api import ( "fmt" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/org" @@ -435,7 +435,7 @@ func (hs *HTTPServer) declareFixedRoles() error { // Metadata helpers // getAccessControlMetadata returns the accesscontrol metadata associated with a given resource -func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, +func (hs *HTTPServer) getAccessControlMetadata(c *contextmodel.ReqContext, orgID int64, prefix string, resourceID string) ac.Metadata { ids := map[string]bool{resourceID: true} return hs.getMultiAccessControlMetadata(c, orgID, prefix, ids)[resourceID] @@ -443,7 +443,7 @@ func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, // getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources // Context must contain permissions in the given org (see LoadPermissionsMiddleware or AuthorizeInOrgMiddleware) -func (hs *HTTPServer) getMultiAccessControlMetadata(c *models.ReqContext, +func (hs *HTTPServer) getMultiAccessControlMetadata(c *contextmodel.ReqContext, orgID int64, prefix string, resourceIDs map[string]bool) map[string]ac.Metadata { if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { return map[string]ac.Metadata{} diff --git a/pkg/api/admin.go b/pkg/api/admin.go index 347aa45c20d..19ea7269cbf 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -5,8 +5,8 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -25,7 +25,7 @@ import ( // 200: adminGetSettingsResponse // 401: unauthorisedError // 403: forbiddenError -func (hs *HTTPServer) AdminGetSettings(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminGetSettings(c *contextmodel.ReqContext) response.Response { settings, err := hs.getAuthorizedSettings(c.Req.Context(), c.SignedInUser, hs.SettingsProvider.Current()) if err != nil { return response.Error(http.StatusForbidden, "Failed to authorize settings", err) @@ -45,7 +45,7 @@ func (hs *HTTPServer) AdminGetSettings(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminGetStats(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminGetStats(c *contextmodel.ReqContext) response.Response { statsQuery := stats.GetAdminStatsQuery{} if err := hs.statsService.GetAdminStats(c.Req.Context(), &statsQuery); err != nil { diff --git a/pkg/api/admin_encryption.go b/pkg/api/admin_encryption.go index 9f00aa2efd5..e6d850d292a 100644 --- a/pkg/api/admin_encryption.go +++ b/pkg/api/admin_encryption.go @@ -5,11 +5,11 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" skv "github.com/grafana/grafana/pkg/services/secrets/kvstore" ) -func (hs *HTTPServer) AdminRotateDataEncryptionKeys(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminRotateDataEncryptionKeys(c *contextmodel.ReqContext) response.Response { if err := hs.SecretsService.RotateDataKeys(c.Req.Context()); err != nil { return response.Error(http.StatusInternalServerError, "Failed to rotate data keys", err) } @@ -17,7 +17,7 @@ func (hs *HTTPServer) AdminRotateDataEncryptionKeys(c *models.ReqContext) respon return response.Respond(http.StatusNoContent, "") } -func (hs *HTTPServer) AdminReEncryptEncryptionKeys(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminReEncryptEncryptionKeys(c *contextmodel.ReqContext) response.Response { if err := hs.SecretsService.ReEncryptDataKeys(c.Req.Context()); err != nil { return response.Error(http.StatusInternalServerError, "Failed to re-encrypt data keys", err) } @@ -25,7 +25,7 @@ func (hs *HTTPServer) AdminReEncryptEncryptionKeys(c *models.ReqContext) respons return response.Respond(http.StatusOK, "Data encryption keys re-encrypted successfully") } -func (hs *HTTPServer) AdminReEncryptSecrets(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminReEncryptSecrets(c *contextmodel.ReqContext) response.Response { success, err := hs.secretsMigrator.ReEncryptSecrets(c.Req.Context()) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to re-encrypt secrets", err) @@ -38,7 +38,7 @@ func (hs *HTTPServer) AdminReEncryptSecrets(c *models.ReqContext) response.Respo return response.Respond(http.StatusOK, "Secrets re-encrypted successfully") } -func (hs *HTTPServer) AdminRollbackSecrets(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminRollbackSecrets(c *contextmodel.ReqContext) response.Response { success, err := hs.secretsMigrator.RollBackSecrets(c.Req.Context()) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to rollback secrets", err) @@ -53,7 +53,7 @@ func (hs *HTTPServer) AdminRollbackSecrets(c *models.ReqContext) response.Respon // To migrate to the plugin, it must be installed and configured // so as not to lose access to migrated secrets -func (hs *HTTPServer) AdminMigrateSecretsToPlugin(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminMigrateSecretsToPlugin(c *contextmodel.ReqContext) response.Response { if skv.EvaluateRemoteSecretsPlugin(c.Req.Context(), hs.secretsPluginManager, hs.Cfg) != nil { hs.log.Warn("Received secrets plugin migration request while plugin is not available") return response.Respond(http.StatusBadRequest, "Secrets plugin is not available") @@ -68,7 +68,7 @@ func (hs *HTTPServer) AdminMigrateSecretsToPlugin(c *models.ReqContext) response // To migrate from the plugin, it must be installed only // as it is possible the user disabled it and then wants to migrate -func (hs *HTTPServer) AdminMigrateSecretsFromPlugin(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminMigrateSecretsFromPlugin(c *contextmodel.ReqContext) response.Response { if hs.secretsPluginManager.SecretsManager(c.Req.Context()) == nil { hs.log.Warn("Received secrets plugin migration request while plugin is not installed") return response.Respond(http.StatusBadRequest, "Secrets plugin is not installed") @@ -81,7 +81,7 @@ func (hs *HTTPServer) AdminMigrateSecretsFromPlugin(c *models.ReqContext) respon return response.Respond(http.StatusOK, "Secret migration from plugin triggered successfully") } -func (hs *HTTPServer) AdminDeleteAllSecretsManagerPluginSecrets(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminDeleteAllSecretsManagerPluginSecrets(c *contextmodel.ReqContext) response.Response { if hs.secretsPluginManager.SecretsManager(c.Req.Context()) == nil { hs.log.Warn("Received secrets plugin deletion request while plugin is not installed") return response.Respond(http.StatusBadRequest, "Secrets plugin is not installed") diff --git a/pkg/api/admin_provisioning.go b/pkg/api/admin_provisioning.go index d770c0fba02..704a2e51b99 100644 --- a/pkg/api/admin_provisioning.go +++ b/pkg/api/admin_provisioning.go @@ -5,7 +5,7 @@ import ( "errors" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) // swagger:route POST /admin/provisioning/dashboards/reload admin_provisioning adminProvisioningReloadDashboards @@ -23,7 +23,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionDashboards(c.Req.Context()) if err != nil && !errors.Is(err, context.Canceled) { return response.Error(500, "", err) @@ -46,7 +46,7 @@ func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *models.ReqContext) re // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionDatasources(c.Req.Context()) if err != nil { return response.Error(500, "", err) @@ -69,7 +69,7 @@ func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContext) r // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionPlugins(c.Req.Context()) if err != nil { return response.Error(500, "Failed to reload plugins config", err) @@ -92,7 +92,7 @@ func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *models.ReqContext) respo // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionNotifications(c.Req.Context()) if err != nil { return response.Error(500, "", err) @@ -100,7 +100,7 @@ func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) return response.Success("Notifications config reloaded") } -func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *contextmodel.ReqContext) response.Response { err := hs.ProvisioningService.ProvisionAlerting(c.Req.Context()) if err != nil { return response.Error(500, "", err) diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 18628f1114b..0a437904f9c 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -38,7 +40,7 @@ import ( // 403: forbiddenError // 412: preconditionFailedError // 500: internalServerError -func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminCreateUser(c *contextmodel.ReqContext) response.Response { form := dtos.AdminCreateUserForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -104,7 +106,7 @@ func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminUpdateUserPassword(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminUpdateUserPassword(c *contextmodel.ReqContext) response.Response { form := dtos.AdminUpdateUserPasswordForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -156,7 +158,7 @@ func (hs *HTTPServer) AdminUpdateUserPassword(c *models.ReqContext) response.Res // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminUpdateUserPermissions(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminUpdateUserPermissions(c *contextmodel.ReqContext) response.Response { form := dtos.AdminUpdateUserPermissionsForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -193,7 +195,7 @@ func (hs *HTTPServer) AdminUpdateUserPermissions(c *models.ReqContext) response. // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminDeleteUser(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -285,14 +287,14 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AdminDisableUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminDisableUser(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } // External users shouldn't be disabled from API - authInfoQuery := &models.GetAuthInfoQuery{UserId: userID} + authInfoQuery := &login.GetAuthInfoQuery{UserId: userID} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), authInfoQuery); !errors.Is(err, user.ErrUserNotFound) { return response.Error(500, "Could not disable external user", nil) } @@ -328,14 +330,14 @@ func (hs *HTTPServer) AdminDisableUser(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AdminEnableUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminEnableUser(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } // External users shouldn't be disabled from API - authInfoQuery := &models.GetAuthInfoQuery{UserId: userID} + authInfoQuery := &login.GetAuthInfoQuery{UserId: userID} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), authInfoQuery); !errors.Is(err, user.ErrUserNotFound) { return response.Error(500, "Could not enable external user", nil) } @@ -366,7 +368,7 @@ func (hs *HTTPServer) AdminEnableUser(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AdminLogoutUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminLogoutUser(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -392,7 +394,7 @@ func (hs *HTTPServer) AdminLogoutUser(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminGetUserAuthTokens(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -417,7 +419,7 @@ func (hs *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) response.Resp // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AdminRevokeUserAuthToken(c *contextmodel.ReqContext) response.Response { cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index b6e72dc0e59..f6d2fb0aa35 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -13,9 +13,9 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authtest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -242,7 +242,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -267,7 +267,7 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { t.Log("Route handler invoked", "url", c.Req.URL) sc.context = c @@ -295,7 +295,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou sc := setupScenarioContext(t, url) sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -323,7 +323,7 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route sc := setupScenarioContext(t, url) sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID @@ -355,7 +355,7 @@ func adminDisableUserScenario(t *testing.T, desc string, action string, url stri sc.sqlStore = hs.SQLStore sc.authInfoService = authInfoService sc.userService = hs.userService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID @@ -381,7 +381,7 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.sqlStore = hs.SQLStore sc.authInfoService = &logintest.AuthInfoServiceFake{} - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID @@ -402,7 +402,7 @@ func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index e8f0404498f..6aec309aa74 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/guardian" @@ -23,7 +24,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) ValidateOrgAlert(c *models.ReqContext) { +func (hs *HTTPServer) ValidateOrgAlert(c *contextmodel.ReqContext) { id, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64) if err != nil { c.JsonApiErr(http.StatusBadRequest, "alertId is invalid", nil) @@ -51,7 +52,7 @@ func (hs *HTTPServer) ValidateOrgAlert(c *models.ReqContext) { // 200: getDashboardStatesResponse // 400: badRequestError // 500: internalServerError -func (hs *HTTPServer) GetAlertStatesForDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertStatesForDashboard(c *contextmodel.ReqContext) response.Response { dashboardID := c.QueryInt64("dashboardId") if dashboardID == 0 { @@ -78,7 +79,7 @@ func (hs *HTTPServer) GetAlertStatesForDashboard(c *models.ReqContext) response. // 200: getAlertsResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlerts(c *contextmodel.ReqContext) response.Response { dashboardQuery := c.Query("dashboardQuery") dashboardTags := c.QueryStrings("dashboardTag") stringDashboardIDs := c.QueryStrings("dashboardId") @@ -110,7 +111,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response { DashboardIds: dashboardIDs, Type: string(models.DashHitDB), FolderIds: folderIDs, - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, } err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery) @@ -165,7 +166,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response { // 422: unprocessableEntityError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AlertTest(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AlertTest(c *contextmodel.ReqContext) response.Response { dto := dtos.AlertTestCommand{} if err := web.Bind(c.Req, &dto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -219,7 +220,7 @@ func (hs *HTTPServer) AlertTest(c *models.ReqContext) response.Response { // 200: getAlertResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetAlert(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlert(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "alertId is invalid", err) @@ -233,8 +234,8 @@ func (hs *HTTPServer) GetAlert(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, &query.Result) } -func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*models.ReqContext) response.Response { - return func(_ *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*contextmodel.ReqContext) response.Response { + return func(_ *contextmodel.ReqContext) response.Response { if ngalertEnabled { return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers()) } @@ -255,7 +256,7 @@ func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*models.ReqCon // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationLookup(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotificationLookup(c *contextmodel.ReqContext) response.Response { alertNotifications, err := hs.getAlertNotificationsInternal(c) if err != nil { return response.Error(500, "Failed to get alert notifications", err) @@ -281,7 +282,7 @@ func (hs *HTTPServer) GetAlertNotificationLookup(c *models.ReqContext) response. // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotifications(c *contextmodel.ReqContext) response.Response { alertNotifications, err := hs.getAlertNotificationsInternal(c) if err != nil { return response.Error(500, "Failed to get alert notifications", err) @@ -296,7 +297,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, result) } -func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*alertmodels.AlertNotification, error) { +func (hs *HTTPServer) getAlertNotificationsInternal(c *contextmodel.ReqContext) ([]*alertmodels.AlertNotification, error) { query := &alertmodels.GetAllAlertNotificationsQuery{OrgId: c.OrgID} if err := hs.AlertNotificationService.GetAllAlertNotifications(c.Req.Context(), query); err != nil { @@ -318,7 +319,7 @@ func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*al // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotificationByID(c *contextmodel.ReqContext) response.Response { notificationId, err := strconv.ParseInt(web.Params(c.Req)[":notificationId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "notificationId is invalid", err) @@ -355,7 +356,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { query := &alertmodels.GetAlertNotificationsWithUidQuery{ OrgId: c.OrgID, Uid: web.Params(c.Req)[":uid"], @@ -388,7 +389,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreateAlertNotification(c *contextmodel.ReqContext) response.Response { cmd := alertmodels.CreateAlertNotificationCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -421,7 +422,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateAlertNotification(c *contextmodel.ReqContext) response.Response { cmd := alertmodels.UpdateAlertNotificationCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -468,7 +469,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { cmd := alertmodels.UpdateAlertNotificationWithUidCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -568,7 +569,7 @@ func (hs *HTTPServer) fillWithSecureSettingsDataByUID(ctx context.Context, cmd * // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteAlertNotification(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteAlertNotification(c *contextmodel.ReqContext) response.Response { notificationId, err := strconv.ParseInt(web.Params(c.Req)[":notificationId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "notificationId is invalid", err) @@ -601,7 +602,7 @@ func (hs *HTTPServer) DeleteAlertNotification(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteAlertNotificationByUID(c *contextmodel.ReqContext) response.Response { cmd := alertmodels.DeleteAlertNotificationWithUidCommand{ OrgId: c.OrgID, Uid: web.Params(c.Req)[":uid"], @@ -633,7 +634,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons // 403: forbiddenError // 412: SMTPNotEnabledError // 500: internalServerError -func (hs *HTTPServer) NotificationTest(c *models.ReqContext) response.Response { +func (hs *HTTPServer) NotificationTest(c *contextmodel.ReqContext) response.Response { dto := dtos.NotificationTestCommand{} if err := web.Bind(c.Req, &dto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -672,14 +673,14 @@ func (hs *HTTPServer) NotificationTest(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) PauseAlert(legacyAlertingEnabled *bool) func(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PauseAlert(legacyAlertingEnabled *bool) func(c *contextmodel.ReqContext) response.Response { if legacyAlertingEnabled == nil || !*legacyAlertingEnabled { - return func(_ *models.ReqContext) response.Response { + return func(_ *contextmodel.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil) } } - return func(c *models.ReqContext) response.Response { + return func(c *contextmodel.ReqContext) response.Response { dto := dtos.PauseAlertCommand{} if err := web.Bind(c.Req, &dto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -754,14 +755,14 @@ func (hs *HTTPServer) PauseAlert(legacyAlertingEnabled *bool) func(c *models.Req // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) PauseAllAlerts(legacyAlertingEnabled *bool) func(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PauseAllAlerts(legacyAlertingEnabled *bool) func(c *contextmodel.ReqContext) response.Response { if legacyAlertingEnabled == nil || !*legacyAlertingEnabled { - return func(_ *models.ReqContext) response.Response { + return func(_ *contextmodel.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil) } } - return func(c *models.ReqContext) response.Response { + return func(c *contextmodel.ReqContext) response.Response { dto := dtos.PauseAllAlertsCommand{} if err := web.Bind(c.Req, &dto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index d48132b769b..4937349c09e 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -9,9 +9,9 @@ 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/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" @@ -31,7 +31,7 @@ import ( // 200: getAnnotationsResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAnnotations(c *contextmodel.ReqContext) response.Response { query := &annotations.ItemQuery{ From: c.QueryInt64("from"), To: c.QueryInt64("to"), @@ -51,7 +51,7 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { // When dashboard UID present in the request, we ignore dashboard ID if query.DashboardUid != "" { dq := dashboards.GetDashboardQuery{UID: query.DashboardUid, OrgID: c.OrgID} - err := hs.DashboardService.GetDashboard(c.Req.Context(), &dq) + dqResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &dq) if err != nil { if hs.Features.IsEnabled(featuremgmt.FlagDashboardsFromStorage) { // OK... the storage UIDs do not (yet?) exist in the DashboardService @@ -59,7 +59,7 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "Invalid dashboard UID in annotation request", err) } } else { - query.DashboardId = dq.Result.ID + query.DashboardId = dqResult.ID } } @@ -80,10 +80,10 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { item.DashboardUID = val } else { query := dashboards.GetDashboardQuery{ID: item.DashboardId, OrgID: c.OrgID} - err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) - if err == nil && query.Result != nil { - item.DashboardUID = &query.Result.UID - dashboardCache[item.DashboardId] = &query.Result.UID + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + if err == nil && queryResult != nil { + item.DashboardUID = &queryResult.UID + dashboardCache[item.DashboardId] = &queryResult.UID } } } @@ -114,7 +114,7 @@ func (e *AnnotationError) Error() string { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) PostAnnotation(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Response { cmd := dtos.PostAnnotationsCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -123,9 +123,9 @@ func (hs *HTTPServer) PostAnnotation(c *models.ReqContext) response.Response { // overwrite dashboardId when dashboardUID is not empty if cmd.DashboardUID != "" { query := dashboards.GetDashboardQuery{OrgID: c.OrgID, UID: cmd.DashboardUID} - err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err == nil { - cmd.DashboardId = query.Result.ID + cmd.DashboardId = queryResult.ID } } @@ -185,7 +185,7 @@ func formatGraphiteAnnotation(what string, data string) string { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) PostGraphiteAnnotation(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PostGraphiteAnnotation(c *contextmodel.ReqContext) response.Response { cmd := dtos.PostGraphiteAnnotationsCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -250,7 +250,7 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *models.ReqContext) response.Resp // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateAnnotation(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Response { cmd := dtos.UpdateAnnotationsCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -306,7 +306,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) PatchAnnotation(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Response { cmd := dtos.PatchAnnotationsCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -371,7 +371,7 @@ func (hs *HTTPServer) PatchAnnotation(c *models.ReqContext) response.Response { // 200: okResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Response { +func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response.Response { cmd := dtos.MassDeleteAnnotationsCmd{} err := web.Bind(c.Req, &cmd) if err != nil { @@ -380,9 +380,9 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Respo if cmd.DashboardUID != "" { query := dashboards.GetDashboardQuery{OrgID: c.OrgID, UID: cmd.DashboardUID} - err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err == nil { - cmd.DashboardId = query.Result.ID + cmd.DashboardId = queryResult.ID } } @@ -447,7 +447,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Respo // 200: getAnnotationByIDResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetAnnotationByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAnnotationByID(c *contextmodel.ReqContext) response.Response { annotationID, err := strconv.ParseInt(web.Params(c.Req)[":annotationId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "annotationId is invalid", err) @@ -476,7 +476,7 @@ func (hs *HTTPServer) GetAnnotationByID(c *models.ReqContext) response.Response // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) DeleteAnnotationByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response.Response { annotationID, err := strconv.ParseInt(web.Params(c.Req)[":annotationId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "annotationId is invalid", err) @@ -502,7 +502,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *models.ReqContext) response.Respon return response.Success("Annotation deleted") } -func (hs *HTTPServer) canSaveAnnotation(c *models.ReqContext, annotation *annotations.ItemDTO) (bool, error) { +func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, annotation *annotations.ItemDTO) (bool, error) { if annotation.GetType() == annotations.Dashboard { return canEditDashboard(c, annotation.DashboardId) } else { @@ -513,7 +513,7 @@ func (hs *HTTPServer) canSaveAnnotation(c *models.ReqContext, annotation *annota } } -func canEditDashboard(c *models.ReqContext, dashboardID int64) (bool, error) { +func canEditDashboard(c *contextmodel.ReqContext, dashboardID int64) (bool, error) { guard, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) if err != nil { return false, err @@ -555,7 +555,7 @@ func findAnnotationByID(ctx context.Context, repo annotations.Repository, annota // 200: getAnnotationTagsResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetAnnotationTags(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAnnotationTags(c *contextmodel.ReqContext) response.Response { query := &annotations.TagsQuery{ OrgID: c.OrgID, Tag: c.Query("tag"), @@ -612,7 +612,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository) (string }) } -func (hs *HTTPServer) canCreateAnnotation(c *models.ReqContext, dashboardId int64) (bool, error) { +func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardId int64) (bool, error) { if dashboardId != 0 { if !hs.AccessControl.IsDisabled() { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeDashboard) @@ -632,7 +632,7 @@ func (hs *HTTPServer) canCreateAnnotation(c *models.ReqContext, dashboardId int6 } } -func (hs *HTTPServer) canMassDeleteAnnotations(c *models.ReqContext, dashboardID int64) (bool, error) { +func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashboardID int64) (bool, error) { if dashboardID == 0 { evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, accesscontrol.ScopeAnnotationsTypeOrganization) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 74219f30f0b..0ec81d2c44a 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -17,11 +17,11 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" @@ -227,13 +227,8 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { t.Run("Should be able to do anything", func(t *testing.T) { dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: q.ID, - UID: q.UID, - } - }).Return(nil) + result := &dashboards.Dashboard{} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(result, nil) postAnnotationScenario(t, "When calling POST on", "/api/annotations", "/api/annotations", role, cmd, store, dashSvc, func(sc *scenarioContext) { setUpACL() sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() @@ -244,6 +239,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { setUpACL() sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) + dashSvc.AssertCalled(t, "GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")) }) @@ -267,13 +263,17 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { }) dashSvc = dashboards.NewFakeDashboardService(t) + result = &dashboards.Dashboard{ + ID: 1, + UID: deleteWithDashboardUIDCmd.DashboardUID, + } dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: 1, + result = &dashboards.Dashboard{ + ID: q.ID, UID: deleteWithDashboardUIDCmd.DashboardUID, } - }).Return(nil) + }).Return(result, nil) deleteAnnotationsScenario(t, "When calling POST with dashboardUID on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteWithDashboardUIDCmd, mockStore, dashSvc, func(sc *scenarioContext) { setUpACL() @@ -287,26 +287,26 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { } func postAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role org.RoleType, - cmd dtos.PostAnnotationsCmd, store db.DB, dashSvc dashboards.DashboardService, fn scenarioFunc) { + cmd dtos.PostAnnotationsCmd, store db.DB, dashSvc *dashboards.FakeDashboardService, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.SQLStore = store hs.DashboardService = dashSvc sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.dashboardService = dashSvc + + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID sc.context.OrgRole = role - return hs.PostAnnotation(c) }) sc.m.Post(routePattern, sc.defaultHandler) - fn(sc) }) } @@ -320,7 +320,7 @@ func putAnnotationScenario(t *testing.T, desc string, url string, routePattern s hs.SQLStore = store sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -345,7 +345,7 @@ func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern hs.SQLStore = store sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -370,7 +370,7 @@ func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePatte hs.DashboardService = dashSvc sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -680,20 +680,22 @@ func setUpACL() { store := dbtest.NewFakeDB() teamSvc := &teamtest.FakeService{} dashSvc := &dashboards.FakeDashboardService{} + qResult := []*dashboards.DashboardACLInfoDTO{ + {Role: &viewerRole, Permission: dashboards.PERMISSION_VIEW}, + {Role: &editorRole, Permission: dashboards.PERMISSION_EDIT}, + } dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Role: &viewerRole, Permission: models.PERMISSION_VIEW}, - {Role: &editorRole, Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + // q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) + + }).Return(qResult, nil) + var result *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + result = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, } - }).Return(nil) + }).Return(result, nil) guardian.InitLegacyGuardian(store, dashSvc, teamSvc) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 89b0749a59b..154c4156930 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -33,11 +33,11 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" @@ -173,8 +173,8 @@ func (hs *HTTPServer) registerRoutes() { ) } - r.Get("/explore", authorize(func(c *models.ReqContext) { - if f, ok := reqSignedIn.(func(c *models.ReqContext)); ok { + r.Get("/explore", authorize(func(c *contextmodel.ReqContext) { + if f, ok := reqSignedIn.(func(c *contextmodel.ReqContext)); ok { f(c) } middleware.EnsureEditorOrViewerCanEdit(c) @@ -233,14 +233,16 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Get("/orgs", routing.Wrap(hs.GetSignedInUserOrgList)) userRoute.Get("/teams", routing.Wrap(hs.GetSignedInUserTeamList)) - userRoute.Get("/stars", routing.Wrap(hs.GetStars)) + userRoute.Get("/stars", routing.Wrap(hs.starApi.GetStars)) // Deprecated: use /stars/dashboard/uid/:uid API instead. - userRoute.Post("/stars/dashboard/:id", routing.Wrap(hs.StarDashboard)) + // nolint:staticcheck + userRoute.Post("/stars/dashboard/:id", routing.Wrap(hs.starApi.StarDashboard)) // Deprecated: use /stars/dashboard/uid/:uid API instead. - userRoute.Delete("/stars/dashboard/:id", routing.Wrap(hs.UnstarDashboard)) + // nolint:staticcheck + userRoute.Delete("/stars/dashboard/:id", routing.Wrap(hs.starApi.UnstarDashboard)) - userRoute.Post("/stars/dashboard/uid/:uid", routing.Wrap(hs.StarDashboardByUID)) - userRoute.Delete("/stars/dashboard/uid/:uid", routing.Wrap(hs.UnstarDashboardByUID)) + userRoute.Post("/stars/dashboard/uid/:uid", routing.Wrap(hs.starApi.StarDashboardByUID)) + userRoute.Delete("/stars/dashboard/uid/:uid", routing.Wrap(hs.starApi.UnstarDashboardByUID)) userRoute.Put("/password", routing.Wrap(hs.ChangeUserPassword)) userRoute.Get("/quotas", routing.Wrap(hs.GetUserQuotas)) @@ -668,7 +670,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/ldap/sync/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersSync)), routing.Wrap(hs.PostSyncUserWithLDAP)) adminRoute.Get("/ldap/:username", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersRead)), routing.Wrap(hs.GetUserFromLDAP)) adminRoute.Get("/ldap/status", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), routing.Wrap(hs.GetLDAPStatus)) - }) + }, reqSignedIn) // Administering users r.Group("/api/admin/users", func(adminUserRoute routing.RouteRegister) { @@ -686,7 +688,7 @@ func (hs *HTTPServer) registerRoutes() { adminUserRoute.Post("/:id/logout", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser)) adminUserRoute.Get("/:id/auth-tokens", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens)) adminUserRoute.Post("/:id/revoke-auth-token", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenUpdate, userIDScope)), routing.Wrap(hs.AdminRevokeUserAuthToken)) - }) + }, reqSignedIn) // rendering r.Get("/render/*", reqSignedIn, hs.RenderToPng) @@ -699,7 +701,7 @@ func (hs *HTTPServer) registerRoutes() { // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) - r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) + r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot)) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 79a89680f8a..02ba9b6cfce 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/apikeygen" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) @@ -26,7 +26,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetAPIKeys(c *contextmodel.ReqContext) response.Response { query := apikey.GetApiKeysQuery{OrgId: c.OrgID, User: c.SignedInUser, IncludeExpired: c.QueryBool("includeExpired")} if err := hs.apiKeyService.GetAPIKeys(c.Req.Context(), &query); err != nil { @@ -70,7 +70,7 @@ func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteAPIKey(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteAPIKey(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -104,7 +104,7 @@ func (hs *HTTPServer) DeleteAPIKey(c *models.ReqContext) response.Response { // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddAPIKey(c *contextmodel.ReqContext) response.Response { cmd := apikey.AddCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 6ba3e485c88..bcfe2eb77c6 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -21,7 +21,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" gocache "github.com/patrickmn/go-cache" @@ -98,7 +98,7 @@ type AvatarCacheServer struct { var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$") -func (a *AvatarCacheServer) Handler(ctx *models.ReqContext) { +func (a *AvatarCacheServer) Handler(ctx *contextmodel.ReqContext) { hash := web.Params(ctx.Req)[":hash"] if len(hash) != 32 || !validMD5.MatchString(hash) { diff --git a/pkg/api/comments.go b/pkg/api/comments.go index b1f7a88f8f6..3a3d3c52b42 100644 --- a/pkg/api/comments.go +++ b/pkg/api/comments.go @@ -5,14 +5,14 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/comments" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response { +func (hs *HTTPServer) commentsGet(c *contextmodel.ReqContext) response.Response { cmd := comments.GetCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -29,7 +29,7 @@ func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response { }) } -func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response { +func (hs *HTTPServer) commentsCreate(c *contextmodel.ReqContext) response.Response { cmd := comments.CreateCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index bb96b785526..3e74a3e2ebc 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -35,11 +35,13 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/login" @@ -75,7 +77,7 @@ func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url sc := setupScenarioContext(t, url) sc.sqlStore = sqlStore sc.userService = usertest.NewUserServiceFake() - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID @@ -101,7 +103,7 @@ func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url func anonymousUserScenario(t *testing.T, desc string, method string, url string, routePattern string, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c if sc.handlerFunc != nil { return sc.handlerFunc(sc.context) @@ -177,7 +179,7 @@ type scenarioContext struct { t *testing.T cfg *setting.Cfg m *web.Mux - context *models.ReqContext + context *contextmodel.ReqContext resp *httptest.ResponseRecorder handlerFunc handlerFunc defaultHandler web.Handler @@ -188,6 +190,7 @@ type scenarioContext struct { authInfoService *logintest.AuthInfoServiceFake dashboardVersionService dashver.Service userService user.Service + dashboardService dashboards.DashboardService } func (sc *scenarioContext) exec() { @@ -195,7 +198,7 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *models.ReqContext) response.Response +type handlerFunc func(c *contextmodel.ReqContext) response.Response func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { t.Helper() @@ -264,7 +267,7 @@ type accessControlScenarioContext struct { // initCtx is used in a middleware to set the initial context // of the request server side. Can be used to pretend sign in. - initCtx *models.ReqContext + initCtx *contextmodel.ReqContext // hs is a minimal HTTPServer for the accesscontrol tests to pass. hs *HTTPServer @@ -300,17 +303,17 @@ func userWithPermissions(orgID int64, permissions []accesscontrol.Permission) *u } // setInitCtxSignedInUser sets a copy of the user in initCtx -func setInitCtxSignedInUser(initCtx *models.ReqContext, user user.SignedInUser) { +func setInitCtxSignedInUser(initCtx *contextmodel.ReqContext, user user.SignedInUser) { initCtx.IsSignedIn = true initCtx.SignedInUser = &user } -func setInitCtxSignedInViewer(initCtx *models.ReqContext) { +func setInitCtxSignedInViewer(initCtx *contextmodel.ReqContext) { initCtx.IsSignedIn = true initCtx.SignedInUser = &user.SignedInUser{UserID: testUserID, OrgID: 1, OrgRole: org.RoleViewer, Login: testUserLogin} } -func setInitCtxSignedInOrgAdmin(initCtx *models.ReqContext) { +func setInitCtxSignedInOrgAdmin(initCtx *contextmodel.ReqContext) { initCtx.IsSignedIn = true initCtx.SignedInUser = &user.SignedInUser{UserID: testUserID, OrgID: 1, OrgRole: org.RoleAdmin, Login: testUserLogin} } @@ -391,6 +394,8 @@ func setupHTTPServerWithCfgDb( folderPermissionsService := accesscontrolmock.NewMockedPermissionsService() dashboardPermissionsService := accesscontrolmock.NewMockedPermissionsService() + folderSvc := foldertest.NewFakeService() + // Create minimal HTTP Server hs := &HTTPServer{ Cfg: cfg, @@ -407,6 +412,7 @@ func setupHTTPServerWithCfgDb( DashboardService: dashboardservice.ProvideDashboardService( cfg, dashboardsStore, dashboardsStore, nil, features, folderPermissionsService, dashboardPermissionsService, ac, + folderSvc, ), preferenceService: preftest.NewPreferenceServiceFake(), userService: userSvc, @@ -429,7 +435,7 @@ func setupHTTPServerWithCfgDb( m := web.New() // middleware to set the test initial context - initCtx := &models.ReqContext{} + initCtx := &contextmodel.ReqContext{} m.Use(func(c *web.Context) { initCtx.Context = c initCtx.Logger = log.New("api-test") @@ -538,10 +544,8 @@ func setUp(confs ...setUpConf) *HTTPServer { } teamSvc := &teamtest.FakeService{} dashSvc := &dashboards.FakeDashboardService{} - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = aclMockResp - }).Return(nil) + qResult := aclMockResp + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(store, dashSvc, teamSvc) return hs } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index a2e051d23ef..ce2ca9d31aa 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -18,9 +18,9 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/kinds/dashboard" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -39,7 +39,7 @@ const ( anonString = "Anonymous" ) -func (hs *HTTPServer) isDashboardStarredByUser(c *models.ReqContext, dashID int64) (bool, error) { +func (hs *HTTPServer) isDashboardStarredByUser(c *contextmodel.ReqContext, dashID int64) (bool, error) { if !c.IsSignedIn { return false, nil } @@ -63,7 +63,7 @@ func dashboardGuardianResponse(err error) response.Response { // 200: trimDashboardResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) TrimDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) TrimDashboard(c *contextmodel.ReqContext) response.Response { cmd := dashboards.TrimDashboardCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -93,7 +93,7 @@ func (hs *HTTPServer) TrimDashboard(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.OrgID, 0, uid) if rsp != nil { @@ -190,15 +190,16 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { // lookup folder title if dash.FolderID > 0 { query := dashboards.GetDashboardQuery{ID: dash.FolderID, OrgID: c.OrgID} - if err := hs.DashboardService.GetDashboard(c.Req.Context(), &query); err != nil { + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + if err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { return response.Error(404, "Folder not found", err) } return response.Error(500, "Dashboard folder could not be read", err) } - meta.FolderUid = query.Result.UID - meta.FolderTitle = query.Result.Title - meta.FolderUrl = query.Result.GetURL() + meta.FolderUid = queryResult.UID + meta.FolderTitle = queryResult.Title + meta.FolderUrl = queryResult.GetURL() } provisioningData, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardID(c.Req.Context(), dash.ID) @@ -241,7 +242,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, dto) } -func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) { +func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext, actions *dtos.AnnotationActions, scope string) { var err error evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope) @@ -281,11 +282,12 @@ func (hs *HTTPServer) getDashboardHelper(ctx context.Context, orgID int64, id in query = dashboards.GetDashboardQuery{ID: id, OrgID: orgID} } - if err := hs.DashboardService.GetDashboard(ctx, &query); err != nil { + queryResult, err := hs.DashboardService.GetDashboard(ctx, &query) + if err != nil { return nil, response.Error(404, "Dashboard not found", err) } - return query.Result, nil + return queryResult, nil } // DeleteDashboardByUID swagger:route DELETE /dashboards/uid/{uid} dashboards deleteDashboardByUID @@ -300,11 +302,11 @@ func (hs *HTTPServer) getDashboardHelper(ctx context.Context, orgID int64, id in // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteDashboardByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDashboardByUID(c *contextmodel.ReqContext) response.Response { return hs.deleteDashboard(c) } -func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Response { dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.OrgID, 0, web.Params(c.Req)[":uid"]) if rsp != nil { return rsp @@ -363,7 +365,7 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { // 412: preconditionFailedError // 422: unprocessableEntityError // 500: internalServerError -func (hs *HTTPServer) PostDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PostDashboard(c *contextmodel.ReqContext) response.Response { cmd := dashboards.SaveDashboardCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -394,7 +396,7 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext) response.Response { return hs.postDashboard(c, cmd) } -func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd dashboards.SaveDashboardCommand) response.Response { +func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.SaveDashboardCommand) response.Response { ctx := c.Req.Context() var err error cmd.OrgID = c.OrgID @@ -515,7 +517,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd dashboards.SaveDas // 200: getHomeDashboardResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetHomeDashboard(c *contextmodel.ReqContext) response.Response { prefsQuery := pref.GetPreferenceWithDefaultsQuery{OrgID: c.OrgID, UserID: c.SignedInUser.UserID, Teams: c.Teams} homePage := hs.Cfg.HomePage @@ -531,9 +533,9 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { if preference.HomeDashboardID != 0 { slugQuery := dashboards.GetDashboardRefByIDQuery{ID: preference.HomeDashboardID} - err := hs.DashboardService.GetDashboardUIDByID(c.Req.Context(), &slugQuery) + slugQueryResult, err := hs.DashboardService.GetDashboardUIDByID(c.Req.Context(), &slugQuery) if err == nil { - url := dashboards.GetDashboardURL(slugQuery.Result.UID, slugQuery.Result.Slug) + url := dashboards.GetDashboardURL(slugQueryResult.UID, slugQueryResult.Slug) dashRedirect := dtos.DashboardRedirect{RedirectUri: url} return response.JSON(http.StatusOK, &dashRedirect) } @@ -573,7 +575,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, &dash) } -func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *models.ReqContext, dash *simplejson.Json) { +func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *contextmodel.ReqContext, dash *simplejson.Json) { // We only add this getting started panel for Admins who have not dismissed it, // and if a custom default home dashboard hasn't been configured if !c.HasUserRole(org.RoleAdmin) || @@ -624,7 +626,7 @@ func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *models.ReqContext // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDashboardVersions(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.Response { var dashID int64 var err error @@ -707,7 +709,7 @@ func (hs *HTTPServer) GetDashboardVersions(c *models.ReqContext) response.Respon // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDashboardVersion(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.Response { var dashID int64 var err error @@ -782,7 +784,7 @@ func (hs *HTTPServer) GetDashboardVersion(c *models.ReqContext) response.Respons // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) ValidateDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ValidateDashboard(c *contextmodel.ReqContext) response.Response { cmd := dashboards.ValidateDashboardCommand{} if err := web.Bind(c.Req, &cmd); err != nil { @@ -844,7 +846,7 @@ func (hs *HTTPServer) ValidateDashboard(c *models.ReqContext) response.Response // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) CalculateDashboardDiff(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CalculateDashboardDiff(c *contextmodel.ReqContext) response.Response { apiOptions := dtos.CalculateDiffOptions{} if err := web.Bind(c.Req, &apiOptions); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -956,7 +958,7 @@ func (hs *HTTPServer) CalculateDashboardDiff(c *models.ReqContext) response.Resp // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) RestoreDashboardVersion(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) response.Response { var dashID int64 var err error @@ -1014,19 +1016,19 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *models.ReqContext) response.Res // 200: getDashboardsTagsResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetDashboardTags(c *models.ReqContext) { +func (hs *HTTPServer) GetDashboardTags(c *contextmodel.ReqContext) { query := dashboards.GetDashboardTagsQuery{OrgID: c.OrgID} - err := hs.DashboardService.GetDashboardTags(c.Req.Context(), &query) + queryResult, err := hs.DashboardService.GetDashboardTags(c.Req.Context(), &query) if err != nil { c.JsonApiErr(500, "Failed to get tags from database", err) return } - c.JSON(http.StatusOK, query.Result) + c.JSON(http.StatusOK, queryResult) } // GetDashboardUIDs converts internal ids to UIDs -func (hs *HTTPServer) GetDashboardUIDs(c *models.ReqContext) { +func (hs *HTTPServer) GetDashboardUIDs(c *contextmodel.ReqContext) { ids := strings.Split(web.Params(c.Req)[":ids"], ",") uids := make([]string, 0, len(ids)) @@ -1037,11 +1039,11 @@ func (hs *HTTPServer) GetDashboardUIDs(c *models.ReqContext) { continue } q.ID = id - err = hs.DashboardService.GetDashboardUIDByID(c.Req.Context(), q) + qResult, err := hs.DashboardService.GetDashboardUIDByID(c.Req.Context(), q) if err != nil { continue } - uids = append(uids, q.Result.UID) + uids = append(uids, qResult.UID) } c.JSON(http.StatusOK, uids) } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 6c4608bb141..9f83b6e7579 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -9,8 +9,8 @@ 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/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/web" @@ -41,7 +41,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDashboardPermissionList(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDashboardPermissionList(c *contextmodel.ReqContext) response.Response { var dashID int64 var err error dashUID := web.Params(c.Req)[":uid"] @@ -123,7 +123,7 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *models.ReqContext) response. // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateDashboardPermissions(c *contextmodel.ReqContext) response.Response { var dashID int64 var err error apiCmd := dtos.UpdateDashboardACLCommand{} @@ -176,7 +176,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response. } items = append(items, hiddenACL...) - if okToUpdate, err := g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, items); err != nil || !okToUpdate { + if okToUpdate, err := g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, items); err != nil || !okToUpdate { if err != nil { if errors.Is(err, guardian.ErrGuardianPermissionExists) || errors.Is(err, guardian.ErrGuardianOverride) { return response.Error(400, err.Error(), err) @@ -200,8 +200,8 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response. } if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), dashID, items); err != nil { - if errors.Is(err, models.ErrDashboardACLInfoMissing) || - errors.Is(err, models.ErrDashboardPermissionDashboardEmpty) { + if errors.Is(err, dashboards.ErrDashboardACLInfoMissing) || + errors.Is(err, dashboards.ErrDashboardPermissionDashboardEmpty) { return response.Error(409, err.Error(), err) } return response.Error(500, "Failed to create permission", err) @@ -275,11 +275,11 @@ func (hs *HTTPServer) updateDashboardAccessControl(ctx context.Context, orgID in func validatePermissionsUpdate(apiCmd dtos.UpdateDashboardACLCommand) error { for _, item := range apiCmd.Items { if item.UserID > 0 && item.TeamID > 0 { - return models.ErrPermissionsWithUserAndTeamNotAllowed + return dashboards.ErrPermissionsWithUserAndTeamNotAllowed } if (item.UserID > 0 || item.TeamID > 0) && item.Role != nil { - return models.ErrPermissionsWithRoleNotAllowed + return dashboards.ErrPermissionsWithRoleNotAllowed } } return nil diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index 369b7858a6d..6088acfdf96 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -12,12 +12,15 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/infra/tracing" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" @@ -27,13 +30,8 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { t.Run("Dashboard permissions test", func(t *testing.T) { settings := setting.NewCfg() dashboardStore := &dashboards.FakeDashboardStore{} - dashboardStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: q.ID, - UID: q.UID, - } - }).Return(nil, nil) + qResult := &dashboards.Dashboard{} + dashboardStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) defer dashboardStore.AssertExpectations(t) features := featuremgmt.WithFeatures() @@ -42,12 +40,14 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { folderPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() + folderSvc := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), settings, dashboardStore, dashboards.NewFakeFolderStore(t), mockSQLStore, featuremgmt.WithFeatures(), nil) hs := &HTTPServer{ Cfg: settings, SQLStore: mockSQLStore, Features: features, DashboardService: dashboardservice.ProvideDashboardService( settings, dashboardStore, dashboards.NewFakeFolderStore(t), nil, features, folderPermissions, dashboardPermissions, ac, + folderSvc, ), AccessControl: accesscontrolmock.New().WithDisabled(), } @@ -67,7 +67,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -94,11 +94,11 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { CanAdminValue: true, CheckPermissionBeforeUpdateValue: true, GetACLValue: []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 1, UserID: 2, Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, UserID: 3, Permission: models.PERMISSION_EDIT}, - {OrgID: 1, DashboardID: 1, UserID: 4, Permission: models.PERMISSION_ADMIN}, - {OrgID: 1, DashboardID: 1, TeamID: 1, Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, TeamID: 2, Permission: models.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, UserID: 2, Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 3, Permission: dashboards.PERMISSION_EDIT}, + {OrgID: 1, DashboardID: 1, UserID: 4, Permission: dashboards.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, TeamID: 1, Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, TeamID: 2, Permission: dashboards.PERMISSION_ADMIN}, }, }) @@ -113,12 +113,12 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 5) assert.Equal(t, int64(2), resp[0].UserID) - assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission) + assert.Equal(t, dashboards.PERMISSION_VIEW, resp[0].Permission) }, mockSQLStore) cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -147,7 +147,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, TeamID: 1, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, TeamID: 1, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -161,7 +161,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Equal(t, 400, sc.resp.Code) respJSON, err := jsonMap(sc.resp.Body.Bytes()) require.NoError(t, err) - assert.Equal(t, models.ErrPermissionsWithUserAndTeamNotAllowed.Error(), respJSON["error"]) + assert.Equal(t, dashboards.ErrPermissionsWithUserAndTeamNotAllowed.Error(), respJSON["error"]) }, }, hs) }) @@ -179,7 +179,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -200,12 +200,12 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { cmds := []dtos.UpdateDashboardACLCommand{ { Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN, Role: &role}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN, Role: &role}, }, }, { Items: []dtos.DashboardACLUpdateItem{ - {TeamID: 1000, Permission: models.PERMISSION_ADMIN, Role: &role}, + {TeamID: 1000, Permission: dashboards.PERMISSION_ADMIN, Role: &role}, }, }, } @@ -221,7 +221,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Equal(t, 400, sc.resp.Code) respJSON, err := jsonMap(sc.resp.Body.Bytes()) require.NoError(t, err) - assert.Equal(t, models.ErrPermissionsWithRoleNotAllowed.Error(), respJSON["error"]) + assert.Equal(t, dashboards.ErrPermissionsWithRoleNotAllowed.Error(), respJSON["error"]) }, }, hs) } @@ -241,7 +241,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -277,12 +277,12 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { CanAdminValue: true, CheckPermissionBeforeUpdateValue: true, GetACLValue: []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 1, UserID: 2, UserLogin: "hiddenUser", Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, UserID: 3, UserLogin: testUserLogin, Permission: models.PERMISSION_EDIT}, - {OrgID: 1, DashboardID: 1, UserID: 4, UserLogin: "user_1", Permission: models.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, UserID: 2, UserLogin: "hiddenUser", Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 3, UserLogin: testUserLogin, Permission: dashboards.PERMISSION_EDIT}, + {OrgID: 1, DashboardID: 1, UserID: 4, UserLogin: "user_1", Permission: dashboards.PERMISSION_ADMIN}, }, GetHiddenACLValue: []*dashboards.DashboardACL{ - {OrgID: 1, DashboardID: 1, UserID: 2, Permission: models.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 2, Permission: dashboards.PERMISSION_VIEW}, }, }) @@ -294,14 +294,14 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 2) assert.Equal(t, int64(3), resp[0].UserID) - assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission) + assert.Equal(t, dashboards.PERMISSION_EDIT, resp[0].Permission) assert.Equal(t, int64(4), resp[1].UserID) - assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission) + assert.Equal(t, dashboards.PERMISSION_ADMIN, resp[1].Permission) }, mockSQLStore) cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } for _, acl := range resp { @@ -354,7 +354,7 @@ func updateDashboardPermissionScenario(t *testing.T, ctx updatePermissionContext t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { sc := setupScenarioContext(t, ctx.url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index ff7d8da6124..a367c69dd5c 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/guardian" @@ -33,11 +33,12 @@ var client = &http.Client{ // Responses: // 200: getSharingOptionsResponse // 401: unauthorisedError -func GetSharingOptions(c *models.ReqContext) { +func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) { c.JSON(http.StatusOK, util.DynMap{ - "externalSnapshotURL": setting.ExternalSnapshotUrl, - "externalSnapshotName": setting.ExternalSnapshotName, - "externalEnabled": setting.ExternalEnabled, + "snapshotEnabled": hs.Cfg.SnapshotEnabled, + "externalSnapshotURL": hs.Cfg.ExternalSnapshotUrl, + "externalSnapshotName": hs.Cfg.ExternalSnapshotName, + "externalEnabled": hs.Cfg.ExternalEnabled, }) } @@ -48,7 +49,7 @@ type CreateExternalSnapshotResponse struct { DeleteUrl string `json:"deleteUrl"` } -func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) { +func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) { var createSnapshotResponse CreateExternalSnapshotResponse message := map[string]interface{}{ "name": cmd.Name, @@ -63,28 +64,28 @@ func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnaps return nil, err } - response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) + resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) if err != nil { return nil, err } defer func() { - if err := response.Body.Close(); err != nil { + if err := resp.Body.Close(); err != nil { plog.Warn("Failed to close response body", "err", err) } }() - if response.StatusCode != 200 { - return nil, fmt.Errorf("create external snapshot response status code %d", response.StatusCode) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode) } - if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil { return nil, err } return &createSnapshotResponse, nil } -func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { +func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { dashUID := cmd.Dashboard.Get("uid").MustString("") if ok := util.IsValidShortUID(dashUID); !ok { return "", fmt.Errorf("invalid dashboard UID") @@ -104,7 +105,12 @@ func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDas // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -114,31 +120,31 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res } var snapshotUrl string - cmd.ExternalUrl = "" - cmd.OrgId = c.OrgID - cmd.UserId = c.UserID - originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd) + cmd.ExternalURL = "" + cmd.OrgID = c.OrgID + cmd.UserID = c.UserID + originalDashboardURL, err := createOriginalDashboardURL(&cmd) if err != nil { return response.Error(http.StatusInternalServerError, "Invalid app URL", err) } if cmd.External { - if !setting.ExternalEnabled { + if !hs.Cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return nil } - response, err := createExternalDashboardSnapshot(cmd) + resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err) return nil } - snapshotUrl = response.Url - cmd.Key = response.Key - cmd.DeleteKey = response.DeleteKey - cmd.ExternalUrl = response.Url - cmd.ExternalDeleteUrl = response.DeleteUrl + snapshotUrl = resp.Url + cmd.Key = resp.Key + cmd.DeleteKey = resp.DeleteKey + cmd.ExternalURL = resp.Url + cmd.ExternalDeleteURL = resp.DeleteUrl cmd.Dashboard = simplejson.New() metrics.MApiDashboardSnapshotExternal.Inc() @@ -168,7 +174,8 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotCreate.Inc() } - if err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil { + result, err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd) + if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) return nil } @@ -178,7 +185,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res "deleteKey": cmd.DeleteKey, "url": snapshotUrl, "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), - "id": cmd.Result.Id, + "id": result.ID, }) return nil } @@ -193,7 +200,12 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res // 400: badRequestError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + key := web.Params(c.Req)[":key"] if len(key) == 0 { return response.Error(http.StatusBadRequest, "Empty snapshot key", nil) @@ -201,12 +213,12 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon query := &dashboardsnapshots.GetDashboardSnapshotQuery{Key: key} - err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) + queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Err(err) } - snapshot := query.Result + snapshot := queryResult // expired snapshots should also be removed from db if snapshot.Expires.Before(time.Now()) { @@ -229,26 +241,26 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon } func deleteExternalDashboardSnapshot(externalUrl string) error { - response, err := client.Get(externalUrl) + resp, err := client.Get(externalUrl) if err != nil { return err } defer func() { - if err := response.Body.Close(); err != nil { + if err := resp.Body.Close(); err != nil { plog.Warn("Failed to close response body", "err", err) } }() - if response.StatusCode == 200 { + if resp.StatusCode == 200 { return nil } // Gracefully ignore "snapshot not found" errors as they could have already // been removed either via the cleanup script or by request. - if response.StatusCode == 500 { + if resp.StatusCode == 500 { var respJson map[string]interface{} - if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil { return err } @@ -257,7 +269,7 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { } } - return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", response.StatusCode) + return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode) } // swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey @@ -272,26 +284,31 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *contextmodel.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + key := web.Params(c.Req)[":deleteKey"] if len(key) == 0 { return response.Error(404, "Snapshot not found", nil) } query := &dashboardsnapshots.GetDashboardSnapshotQuery{DeleteKey: key} - err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) + queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Err(err) } - if query.Result.External { - err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if queryResult.External { + err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) if err != nil { return response.Error(500, "Failed to delete external dashboard", err) } } - cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} + cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(500, "Failed to delete dashboard snapshot", err) @@ -299,7 +316,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r return response.JSON(http.StatusOK, util.DynMap{ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", - "id": query.Result.Id, + "id": queryResult.ID, }) } @@ -312,7 +329,12 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + key := web.Params(c.Req)[":key"] if len(key) == 0 { return response.Error(http.StatusNotFound, "Snapshot not found", nil) @@ -320,16 +342,16 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res query := &dashboardsnapshots.GetDashboardSnapshotQuery{Key: key} - err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) + queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Err(err) } - if query.Result == nil { + if queryResult == nil { return response.Error(http.StatusNotFound, "Failed to get dashboard snapshot", nil) } - if query.Result.External { - err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if queryResult.External { + err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) } @@ -339,26 +361,26 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res // which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback // to the user’s org role, which for editors and admins would essentially always be allowed here. With RBAC, // all permissions must be explicit, so the lack of a rule for dashboard 0 means the guardian will reject. - dashboardID := query.Result.Dashboard.Get("id").MustInt64() + dashboardID := queryResult.Dashboard.Get("id").MustInt64() if dashboardID != 0 { - guardian, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) + g, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) if err != nil { return response.Err(err) } - canEdit, err := guardian.CanEdit() + canEdit, err := g.CanEdit() // check for permissions only if the dashboard is found if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err) } - if !canEdit && query.Result.UserId != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) { + if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) { return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil) } } - cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} + cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to delete dashboard snapshot", err) @@ -366,7 +388,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res return response.JSON(http.StatusOK, util.DynMap{ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", - "id": query.Result.Id, + "id": queryResult.ID, }) } @@ -377,7 +399,12 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res // Responses: // 200: searchDashboardSnapshotsResponse // 500: internalServerError -func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchDashboardSnapshots(c *contextmodel.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + query := c.Query("query") limit := c.QueryInt("limit") @@ -388,32 +415,32 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re searchQuery := dashboardsnapshots.GetDashboardSnapshotsQuery{ Name: query, Limit: limit, - OrgId: c.OrgID, + OrgID: c.OrgID, SignedInUser: c.SignedInUser, } - err := hs.dashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery) + searchQueryResult, err := hs.dashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery) if err != nil { return response.Error(500, "Search failed", err) } - dtos := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQuery.Result)) - for i, snapshot := range searchQuery.Result { - dtos[i] = &dashboardsnapshots.DashboardSnapshotDTO{ - Id: snapshot.Id, + dto := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult)) + for i, snapshot := range searchQueryResult { + dto[i] = &dashboardsnapshots.DashboardSnapshotDTO{ + ID: snapshot.ID, Name: snapshot.Name, Key: snapshot.Key, - OrgId: snapshot.OrgId, - UserId: snapshot.UserId, + OrgID: snapshot.OrgID, + UserID: snapshot.UserID, External: snapshot.External, - ExternalUrl: snapshot.ExternalUrl, + ExternalURL: snapshot.ExternalURL, Expires: snapshot.Expires, Created: snapshot.Created, Updated: snapshot.Updated, } } - return response.JSON(http.StatusOK, dtos) + return response.JSON(http.StatusOK, dto) } // swagger:parameters createDashboardSnapshot diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 551fa25ffbd..9512506ef7c 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -15,12 +15,12 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team/teamtest" + "github.com/grafana/grafana/pkg/setting" ) func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { @@ -41,25 +41,22 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { dashSnapSvc := dashboardsnapshots.NewMockService(t) dashSnapSvc.On("DeleteDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.DeleteDashboardSnapshotCommand")).Return(nil).Maybe() - dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboardsnapshots.GetDashboardSnapshotQuery) - res := &dashboardsnapshots.DashboardSnapshot{ - Id: 1, - Key: "12345", - DeleteKey: "54321", - Dashboard: jsonModel, - Expires: time.Now().Add(time.Duration(1000) * time.Second), - UserId: 999999, - } - if userId != 0 { - res.UserId = userId - } - if deleteUrl != "" { - res.External = true - res.ExternalDeleteUrl = deleteUrl - } - q.Result = res - }).Return(nil) + res := &dashboardsnapshots.DashboardSnapshot{ + ID: 1, + Key: "12345", + DeleteKey: "54321", + Dashboard: jsonModel, + Expires: time.Now().Add(time.Duration(1000) * time.Second), + UserID: 999999, + } + if userId != 0 { + res.UserID = userId + } + if deleteUrl != "" { + res.External = true + res.ExternalDeleteURL = deleteUrl + } + dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Return(res, nil) dashSnapSvc.On("DeleteDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.DeleteDashboardSnapshotCommand")).Return(nil).Maybe() return dashSnapSvc } @@ -67,19 +64,21 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { t.Run("When user has editor role and is not in the ACL", func(t *testing.T) { loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} + d := setUpSnapshotTest(t, 0, "") + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot teamSvc := &teamtest.FakeService{} dashSvc := dashboards.NewFakeDashboardService(t) + var qResult *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResult = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, } - }).Return(nil).Maybe() - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil).Maybe() + }).Return(qResult, nil).Maybe() + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil, nil).Maybe() hs.DashboardService = dashSvc guardian.InitLegacyGuardian(sc.sqlStore, dashSvc, teamSvc) @@ -97,7 +96,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { rw.WriteHeader(200) externalRequest = req }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL)} + d := setUpSnapshotTest(t, 0, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -118,13 +118,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { t.Run("When user is editor and dashboard has default ACL", func(t *testing.T) { teamSvc := &teamtest.FakeService{} dashSvc := &dashboards.FakeDashboardService{} - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Role: &viewerRole, Permission: models.PERMISSION_VIEW}, - {Role: &editorRole, Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {Role: &viewerRole, Permission: dashboards.PERMISSION_VIEW}, + {Role: &editorRole, Permission: dashboards.PERMISSION_EDIT}, + } + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) loggedInUserScenarioWithRole(t, "Should be able to delete a snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { @@ -134,22 +132,17 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { externalRequest = req }) dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: q.ID, - OrgID: q.OrgID, - } - }).Return(nil).Maybe() - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Role: &viewerRole, Permission: models.PERMISSION_VIEW}, - {Role: &editorRole, Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + qResult := &dashboards.Dashboard{} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil).Maybe() + qResultACL := []*dashboards.DashboardACLInfoDTO{ + {Role: &viewerRole, Permission: dashboards.PERMISSION_VIEW}, + {Role: &editorRole, Permission: dashboards.PERMISSION_EDIT}, + } + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResultACL, nil) guardian.InitLegacyGuardian(sc.sqlStore, dashSvc, teamSvc) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL), DashboardService: dashSvc} + d := setUpSnapshotTest(t, 0, ts.URL) + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -170,7 +163,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { d := setUpSnapshotTest(t, testUserID, "") dashSvc := dashboards.NewFakeDashboardService(t) - hs := &HTTPServer{dashboardsnapshotsService: d, DashboardService: dashSvc} + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -194,7 +188,9 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { }) dashSvc := dashboards.NewFakeDashboardService(t) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL), DashboardService: dashSvc} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -215,7 +211,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { rw.WriteHeader(500) _, writeErr = rw.Write([]byte(`{"message":"Unexpected"}`)) }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -229,7 +226,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(404) }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -238,7 +236,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} + d := setUpSnapshotTest(t, 0, "") + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() @@ -264,7 +263,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { dashSnapSvc. On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). Run(func(args mock.Arguments) {}). - Return(dashboardsnapshots.ErrBaseNotFound.Errorf("")) + Return(nil, dashboardsnapshots.ErrBaseNotFound.Errorf("")) return dashSnapSvc } @@ -273,7 +272,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() @@ -284,7 +283,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -295,7 +294,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -306,48 +305,94 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { func TestGetDashboardSnapshotFailure(t *testing.T) { sqlmock := dbtest.NewFakeDB() - setUpSnapshotTest := func(t *testing.T) dashboardsnapshots.Service { + setUpSnapshotTest := func(t *testing.T, shouldMockDashSnapServ bool) dashboardsnapshots.Service { t.Helper() - dashSnapSvc := dashboardsnapshots.NewMockService(t) - dashSnapSvc. - On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). - Run(func(args mock.Arguments) {}). - Return(errors.New("something went wrong")) - - return dashSnapSvc + if shouldMockDashSnapServ { + dashSnapSvc := dashboardsnapshots.NewMockService(t) + dashSnapSvc. + On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). + Run(func(args mock.Arguments) {}). + Return(nil, errors.New("something went wrong")) + return dashSnapSvc + } else { + return nil + } } loggedInUserScenarioWithRole(t, "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + loggedInUserScenarioWithRole(t, + "GET /snapshots/{key} should return 403 when snapshot is disabled", "GET", + "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.GetDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) + loggedInUserScenarioWithRole(t, "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + loggedInUserScenarioWithRole(t, + "DELETE /snapshots/{key} should return 403 when snapshot is disabled", "DELETE", + "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) + loggedInUserScenarioWithRole(t, "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + + loggedInUserScenarioWithRole(t, + "GET /snapshots-delete/{deleteKey} should return 403 when snapshot is disabled", "DELETE", + "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) +} + +func buildHttpServer(d dashboardsnapshots.Service, snapshotEnabled bool) *HTTPServer { + hs := &HTTPServer{ + dashboardsnapshotsService: d, + Cfg: &setting.Cfg{ + SnapshotEnabled: snapshotEnabled, + }, + } + return hs } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 5daa4816767..2d2f0d42d22 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -16,11 +16,12 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry/corekind" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -28,6 +29,7 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -35,6 +37,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardversion/dashvertest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" @@ -55,7 +58,7 @@ func TestGetHomeDashboard(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) httpReq.Header.Add("Content-Type", "application/json") - req := &models.ReqContext{SignedInUser: &user.SignedInUser{}, Context: &web.Context{Req: httpReq}} + req := &contextmodel.ReqContext{SignedInUser: &user.SignedInUser{}, Context: &web.Context{Req: httpReq}} cfg := setting.NewCfg() cfg.StaticRootPath = "../../public/" prefService := preftest.NewPreferenceServiceFake() @@ -137,10 +140,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { fakeDashboardVersionService.ExpectedDashboardVersion = &dashver.DashboardVersionDTO{} teamService := &teamtest.FakeService{} dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = fakeDash - }).Return(nil) + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil) mockSQLStore := dbtest.NewFakeDB() hs := &HTTPServer{ @@ -158,13 +158,11 @@ func TestDashboardAPIEndpoint(t *testing.T) { setUp := func() { viewerRole := org.RoleViewer editorRole := org.RoleEditor - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Role: &viewerRole, Permission: models.PERMISSION_VIEW}, - {Role: &editorRole, Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {Role: &viewerRole, Permission: dashboards.PERMISSION_VIEW}, + {Role: &editorRole, Permission: dashboards.PERMISSION_EDIT}, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) } @@ -245,20 +243,16 @@ func TestDashboardAPIEndpoint(t *testing.T) { fakeDashboardVersionService.ExpectedDashboardVersion = &dashver.DashboardVersionDTO{} teamService := &teamtest.FakeService{} dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = fakeDash - }).Return(nil) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - { - DashboardID: 1, - Permission: models.PERMISSION_EDIT, - UserID: 200, - }, - } - }).Return(nil) + + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + { + DashboardID: 1, + Permission: dashboards.PERMISSION_EDIT, + UserID: 200, + }, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) mockSQLStore := dbtest.NewFakeDB() cfg := setting.NewCfg() @@ -381,12 +375,10 @@ func TestDashboardAPIEndpoint(t *testing.T) { setting.ViewersCanEdit = false dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 2, UserID: 1, Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {OrgID: 1, DashboardID: 2, UserID: 1, Permission: dashboards.PERMISSION_EDIT}, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) } @@ -404,10 +396,8 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUpInner() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = dashboards.NewDashboard("test") - }).Return(nil) + qResult := dashboards.NewDashboard("test") + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) dashboardService.On("DeleteDashboard", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil) hs.callDeleteDashboardByUID(t, sc, dashboardService) @@ -443,12 +433,10 @@ func TestDashboardAPIEndpoint(t *testing.T) { setting.ViewersCanEdit = true dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 2, UserID: 1, Permission: models.PERMISSION_VIEW}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {OrgID: 1, DashboardID: 2, UserID: 1, Permission: dashboards.PERMISSION_VIEW}, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) } @@ -483,12 +471,10 @@ func TestDashboardAPIEndpoint(t *testing.T) { setting.ViewersCanEdit = true dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 2, UserID: 1, Permission: models.PERMISSION_ADMIN}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {OrgID: 1, DashboardID: 2, UserID: 1, Permission: dashboards.PERMISSION_ADMIN}, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) } @@ -506,10 +492,8 @@ func TestDashboardAPIEndpoint(t *testing.T) { setUpInner() sc.sqlStore = mockSQLStore dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = dashboards.NewDashboard("test") - }).Return(nil) + qResult := dashboards.NewDashboard("test") + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) dashboardService.On("DeleteDashboard", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil) hs.callDeleteDashboardByUID(t, sc, dashboardService) @@ -536,12 +520,10 @@ func TestDashboardAPIEndpoint(t *testing.T) { setUpInner := func() { dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 2, UserID: 1, Permission: models.PERMISSION_VIEW}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {OrgID: 1, DashboardID: 2, UserID: 1, Permission: dashboards.PERMISSION_VIEW}, + } + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) } @@ -808,14 +790,9 @@ func TestDashboardAPIEndpoint(t *testing.T) { setUp := func() { teamSvc := &teamtest.FakeService{} dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - OrgID: q.OrgID, - ID: q.ID, - } - }).Return(nil) + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil, nil) + qResult := &dashboards.Dashboard{} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) guardian.InitLegacyGuardian(sqlmock, dashSvc, teamSvc) } @@ -860,10 +837,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { fakeDash.HasACL = false dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = fakeDash - }).Return(nil) + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil) dashboardService.On("SaveDashboard", mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool")).Run(func(args mock.Arguments) { cmd := args.Get(1).(*dashboards.SaveDashboardDTO) cmd.Dashboard = &dashboards.Dashboard{ @@ -897,10 +871,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { fakeDash.HasACL = false dashboardService := dashboards.NewFakeDashboardService(t) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = fakeDash - }).Return(nil) + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil) dashboardService.On("SaveDashboard", mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool")).Run(func(args mock.Arguments) { cmd := args.Get(1).(*dashboards.SaveDashboardDTO) cmd.Dashboard = &dashboards.Dashboard{ @@ -937,14 +908,10 @@ func TestDashboardAPIEndpoint(t *testing.T) { dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`)) require.NoError(t, err) - dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ID: 1, Data: dataValue} - }).Return(nil) - dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{{OrgID: testOrgID, DashboardID: 1, UserID: testUserID, Permission: models.PERMISSION_EDIT}} - }).Return(nil) + qResult := &dashboards.Dashboard{ID: 1, Data: dataValue} + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) + qResult2 := []*dashboards.DashboardACLInfoDTO{{OrgID: testOrgID, DashboardID: 1, UserID: testUserID, Permission: dashboards.PERMISSION_EDIT}} + dashboardService.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult2, nil) guardian.InitLegacyGuardian(mockSQLStore, dashboardService, teamService) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { @@ -1016,10 +983,13 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() features := featuremgmt.WithFeatures() + folderSvc := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, db.InitTestDB(t), featuremgmt.WithFeatures(), nil) + if dashboardService == nil { dashboardService = service.ProvideDashboardService( cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions, ac, + folderSvc, ) } @@ -1033,6 +1003,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr dashboardProvisioningService: service.ProvideDashboardService( cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions, ac, + folderSvc, ), DashboardService: dashboardService, Features: featuremgmt.WithFeatures(), @@ -1105,7 +1076,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -1137,7 +1108,7 @@ func postValidateScenario(t *testing.T, desc string, url string, routePattern st } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -1177,7 +1148,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -1219,7 +1190,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout sc := setupScenarioContext(t, url) sc.sqlStore = sqlStore sc.dashboardVersionService = fakeDashboardVersionService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 09edcfd379a..b7be8b84979 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,6 +1,6 @@ package api -import "github.com/grafana/grafana/pkg/models" +import contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" // swagger:route GET /datasources/proxy/{id}/{datasource_proxy_route} datasources datasourceProxyGETcalls // @@ -56,7 +56,7 @@ import "github.com/grafana/grafana/pkg/models" // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) ProxyDataSourceRequest(c *models.ReqContext) { +func (hs *HTTPServer) ProxyDataSourceRequest(c *contextmodel.ReqContext) { hs.DataProxy.ProxyDataSourceRequest(c) } @@ -102,7 +102,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *models.ReqContext) { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) ProxyDataSourceRequestWithUID(c *models.ReqContext) { +func (hs *HTTPServer) ProxyDataSourceRequestWithUID(c *contextmodel.ReqContext) { hs.DataProxy.ProxyDatasourceRequestWithUID(c, "") } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 7e2ff3f32ec..b2efa244fe3 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -17,8 +17,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/adapters" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/user" @@ -42,7 +42,7 @@ var secretsPluginError datasources.ErrDatasourceSecretsPluginUserFriendly // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSources(c *contextmodel.ReqContext) response.Response { query := datasources.GetDataSourcesQuery{OrgId: c.OrgID, DataSourceLimit: hs.Cfg.DataSourceLimit} if err := hs.DataSourcesService.GetDataSources(c.Req.Context(), &query); err != nil { @@ -106,7 +106,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSourceById(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", nil) @@ -151,7 +151,7 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response // 404: notFoundError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) DeleteDataSourceById(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -202,7 +202,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *models.ReqContext) response.Respon // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response { ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.OrgID) if err != nil { @@ -233,7 +233,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteDataSourceByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] if uid == "" { @@ -283,7 +283,7 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *models.ReqContext) response.Respo // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteDataSourceByName(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDataSourceByName(c *contextmodel.ReqContext) response.Response { name := web.Params(c.Req)[":name"] if name == "" { @@ -365,7 +365,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error { // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response { cmd := datasources.AddDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -431,7 +431,7 @@ func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response { // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateDataSourceByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.Response { cmd := datasources.UpdateDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -475,7 +475,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *models.ReqContext) response.Respon // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateDataSourceByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response { cmd := datasources.UpdateDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -500,7 +500,7 @@ func (hs *HTTPServer) UpdateDataSourceByUID(c *models.ReqContext) response.Respo return hs.updateDataSourceByID(c, ds, cmd) } -func (hs *HTTPServer) updateDataSourceByID(c *models.ReqContext, ds *datasources.DataSource, cmd datasources.UpdateDataSourceCommand) response.Response { +func (hs *HTTPServer) updateDataSourceByID(c *contextmodel.ReqContext, ds *datasources.DataSource, cmd datasources.UpdateDataSourceCommand) response.Response { if ds.ReadOnly { return response.Error(403, "Cannot update read-only data source", nil) } @@ -579,7 +579,7 @@ func (hs *HTTPServer) getRawDataSourceByUID(ctx context.Context, uid string, org // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetDataSourceByName(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSourceByName(c *contextmodel.ReqContext) response.Response { query := datasources.GetDataSourceQuery{Name: web.Params(c.Req)[":name"], OrgId: c.OrgID} if err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query); err != nil { @@ -606,7 +606,7 @@ func (hs *HTTPServer) GetDataSourceByName(c *models.ReqContext) response.Respons // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetDataSourceIdByName(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSourceIdByName(c *contextmodel.ReqContext) response.Response { query := datasources.GetDataSourceQuery{Name: web.Params(c.Req)[":name"], OrgId: c.OrgID} if err := hs.DataSourcesService.GetDataSource(c.Req.Context(), &query); err != nil { @@ -639,7 +639,7 @@ func (hs *HTTPServer) GetDataSourceIdByName(c *models.ReqContext) response.Respo // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) CallDatasourceResource(c *models.ReqContext) { +func (hs *HTTPServer) CallDatasourceResource(c *contextmodel.ReqContext) { datasourceID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { c.JsonApiErr(http.StatusBadRequest, "id is invalid", nil) @@ -675,7 +675,7 @@ func (hs *HTTPServer) CallDatasourceResource(c *models.ReqContext) { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) CallDatasourceResourceWithUID(c *models.ReqContext) { +func (hs *HTTPServer) CallDatasourceResourceWithUID(c *contextmodel.ReqContext) { dsUID := web.Params(c.Req)[":uid"] if !util.IsValidShortUID(dsUID) { c.JsonApiErr(http.StatusBadRequest, "UID is invalid", nil) @@ -746,7 +746,7 @@ func (hs *HTTPServer) convertModelToDtos(ctx context.Context, ds *datasources.Da // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) CheckDatasourceHealthWithUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CheckDatasourceHealthWithUID(c *contextmodel.ReqContext) response.Response { dsUID := web.Params(c.Req)[":uid"] if !util.IsValidShortUID(dsUID) { return response.Error(http.StatusBadRequest, "UID is invalid", nil) @@ -776,7 +776,7 @@ func (hs *HTTPServer) CheckDatasourceHealthWithUID(c *models.ReqContext) respons // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CheckDatasourceHealth(c *contextmodel.ReqContext) response.Response { datasourceID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", nil) @@ -792,7 +792,7 @@ func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Respo return hs.checkDatasourceHealth(c, ds) } -func (hs *HTTPServer) checkDatasourceHealth(c *models.ReqContext, ds *datasources.DataSource) response.Response { +func (hs *HTTPServer) checkDatasourceHealth(c *contextmodel.ReqContext, ds *datasources.DataSource) response.Response { plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), ds.Type) if !exists { return response.Error(http.StatusInternalServerError, "Unable to find datasource plugin", nil) diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index f9b313c6b5a..be708e89e6f 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -16,11 +16,11 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/setting" @@ -88,7 +88,7 @@ func TestAddDataSource_InvalidURL(t *testing.T) { Cfg: setting.NewCfg(), } - sc.m.Post(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: "Test", Url: "invalid:url", @@ -119,7 +119,7 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) { sc := setupScenarioContext(t, "/api/datasources") - sc.m.Post(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: name, Url: url, @@ -149,7 +149,7 @@ func TestAddDataSource_InvalidJSONData(t *testing.T) { jsonData := simplejson.New() jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxyHeaderName) - sc.m.Post(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: "Test", Url: "localhost:5432", @@ -173,7 +173,7 @@ func TestUpdateDataSource_InvalidURL(t *testing.T) { } sc := setupScenarioContext(t, "/api/datasources/1234") - sc.m.Put(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: "Test", Url: "invalid:url", @@ -201,7 +201,7 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) { jsonData := simplejson.New() jsonData.Set("httpHeaderName1", hs.Cfg.AuthProxyHeaderName) - sc.m.Put(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: "Test", Url: "localhost:5432", @@ -233,7 +233,7 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) { sc := setupScenarioContext(t, "/api/datasources/1234") - sc.m.Put(sc.url, routing.Wrap(func(c *models.ReqContext) response.Response { + sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ Name: name, Url: url, diff --git a/pkg/api/dtos/acl.go b/pkg/api/dtos/acl.go index ae0f8e9f266..d6f4114157a 100644 --- a/pkg/api/dtos/acl.go +++ b/pkg/api/dtos/acl.go @@ -1,7 +1,7 @@ package dtos import ( - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" ) @@ -21,5 +21,5 @@ type DashboardACLUpdateItem struct { // * `2` - Edit // * `4` - Admin // Enum: 1,2,4 - Permission models.PermissionType `json:"permission"` + Permission dashboards.PermissionType `json:"permission"` } diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index 4dc3d7cc97c..b92ae22f221 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -4,17 +4,6 @@ import ( pref "github.com/grafana/grafana/pkg/services/preference" ) -type Prefs struct { - Theme string `json:"theme"` - HomeDashboardID int64 `json:"homeDashboardId"` - HomeDashboardUID string `json:"homeDashboardUID,omitempty"` - Timezone string `json:"timezone"` - WeekStart string `json:"weekStart"` - Language string `json:"language"` - Navbar pref.NavbarPreference `json:"navbar,omitempty"` - QueryHistory pref.QueryHistoryPreference `json:"queryHistory,omitempty"` -} - // swagger:model type UpdatePrefsCmd struct { // Enum: light,dark @@ -26,7 +15,6 @@ type UpdatePrefsCmd struct { // Enum: utc,browser Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` - Navbar *pref.NavbarPreference `json:"navbar,omitempty"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` Language string `json:"language"` } @@ -42,7 +30,6 @@ type PatchPrefsCmd struct { Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,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 bfc6cad7e90..5470d46bda8 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -31,7 +31,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response { var folders []*folder.Folder var err error if hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) { @@ -82,7 +82,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetFolderByUID(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid, SignedInUser: c.SignedInUser}) if err != nil { @@ -109,7 +109,7 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetFolderByID(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -139,7 +139,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response { cmd := folder.CreateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -167,21 +167,18 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder)) } -func (hs *HTTPServer) MoveFolder(c *models.ReqContext) response.Response { +func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response { if hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) { - cmd := models.MoveFolderCommand{} + cmd := folder.MoveFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } var theFolder *folder.Folder var err error - if cmd.ParentUID != nil { - moveCommand := folder.MoveFolderCommand{ - UID: web.Params(c.Req)[":uid"], - NewParentUID: *cmd.ParentUID, - OrgID: c.OrgID, - } - theFolder, err = hs.folderService.Move(c.Req.Context(), &moveCommand) + if cmd.NewParentUID != "" { + cmd.OrgID = c.OrgID + cmd.UID = web.Params(c.Req)[":uid"] + theFolder, err = hs.folderService.Move(c.Req.Context(), &cmd) if err != nil { return response.Error(http.StatusInternalServerError, "update folder uid failed", err) } @@ -208,7 +205,7 @@ func (hs *HTTPServer) MoveFolder(c *models.ReqContext) response.Response { // 404: notFoundError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response { cmd := folder.UpdateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -243,7 +240,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed +func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { if errors.Is(err, libraryelements.ErrFolderHasConnectedLibraryElements) { @@ -261,7 +258,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // return response.JSON(http.StatusOK, "") } -func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *folder.Folder) dtos.Folder { +func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, g guardian.DashboardGuardian, folder *folder.Folder) dtos.Folder { canEdit, _ := g.CanEdit() canSave, _ := g.CanSave() canAdmin, _ := g.CanAdmin() @@ -280,7 +277,7 @@ func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardG Id: folder.ID, Uid: folder.UID, Title: folder.Title, - Url: folder.Url, + Url: folder.URL, HasACL: folder.HasACL, CanSave: canSave, CanEdit: canEdit, @@ -296,7 +293,7 @@ func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardG } } -func (hs *HTTPServer) searchFolders(c *models.ReqContext) ([]*folder.Folder, error) { +func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext) ([]*folder.Folder, error) { searchQuery := search.Query{ SignedInUser: c.SignedInUser, DashboardIds: make([]int64, 0), @@ -304,7 +301,7 @@ func (hs *HTTPServer) searchFolders(c *models.ReqContext) ([]*folder.Folder, err Limit: c.QueryInt64("limit"), OrgId: c.OrgID, Type: "dash-folder", - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, Page: c.QueryInt64("page"), } diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index ae1ad246c56..d7916f4bb0e 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" @@ -26,7 +26,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetFolderPermissionList(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid, SignedInUser: c.SignedInUser}) @@ -83,7 +83,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateFolderPermissions(c *contextmodel.ReqContext) response.Response { apiCmd := dtos.UpdateDashboardACLCommand{} if err := web.Bind(c.Req, &apiCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -132,7 +132,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res } items = append(items, hiddenACL...) - if okToUpdate, err := g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, items); err != nil || !okToUpdate { + if okToUpdate, err := g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, items); err != nil || !okToUpdate { if err != nil { if errors.Is(err, guardian.ErrGuardianPermissionExists) || errors.Is(err, guardian.ErrGuardianOverride) { @@ -157,14 +157,14 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res } if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), folder.ID, items); err != nil { - if errors.Is(err, models.ErrDashboardACLInfoMissing) { - err = models.ErrFolderACLInfoMissing + if errors.Is(err, dashboards.ErrDashboardACLInfoMissing) { + err = dashboards.ErrFolderACLInfoMissing } - if errors.Is(err, models.ErrDashboardPermissionDashboardEmpty) { - err = models.ErrFolderPermissionFolderEmpty + if errors.Is(err, dashboards.ErrDashboardPermissionDashboardEmpty) { + err = dashboards.ErrFolderPermissionFolderEmpty } - if errors.Is(err, models.ErrFolderACLInfoMissing) || errors.Is(err, models.ErrFolderPermissionFolderEmpty) { + if errors.Is(err, dashboards.ErrFolderACLInfoMissing) || errors.Is(err, dashboards.ErrFolderPermissionFolderEmpty) { return response.Error(409, err.Error(), err) } diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index 56ae1bfa2a6..03eeed7abe8 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -13,8 +13,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" service "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -46,6 +46,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { dashboardPermissionsService: dashboardPermissions, DashboardService: service.ProvideDashboardService( settings, dashboardStore, dashboards.NewFakeFolderStore(t), nil, features, folderPermissions, dashboardPermissions, ac, + folderService, ), AccessControl: accesscontrolmock.New().WithDisabled(), } @@ -63,7 +64,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -97,7 +98,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -123,11 +124,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CanAdminValue: true, CheckPermissionBeforeUpdateValue: true, GetACLValue: []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 1, UserID: 2, Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, UserID: 3, Permission: models.PERMISSION_EDIT}, - {OrgID: 1, DashboardID: 1, UserID: 4, Permission: models.PERMISSION_ADMIN}, - {OrgID: 1, DashboardID: 1, TeamID: 1, Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, TeamID: 2, Permission: models.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, UserID: 2, Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 3, Permission: dashboards.PERMISSION_EDIT}, + {OrgID: 1, DashboardID: 1, UserID: 4, Permission: dashboards.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, TeamID: 1, Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, TeamID: 2, Permission: dashboards.PERMISSION_ADMIN}, }, }) @@ -145,12 +146,12 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 5) assert.Equal(t, int64(2), resp[0].UserID) - assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission) + assert.Equal(t, dashboards.PERMISSION_VIEW, resp[0].Permission) }, mockSQLStore) cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -192,7 +193,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -213,12 +214,12 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { cmds := []dtos.UpdateDashboardACLCommand{ { Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN, Role: &role}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN, Role: &role}, }, }, { Items: []dtos.DashboardACLUpdateItem{ - {TeamID: 1000, Permission: models.PERMISSION_ADMIN, Role: &role}, + {TeamID: 1000, Permission: dashboards.PERMISSION_ADMIN, Role: &role}, }, }, } @@ -234,7 +235,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { assert.Equal(t, 400, sc.resp.Code) respJSON, err := jsonMap(sc.resp.Body.Bytes()) require.NoError(t, err) - assert.Equal(t, models.ErrPermissionsWithRoleNotAllowed.Error(), respJSON["error"]) + assert.Equal(t, dashboards.ErrPermissionsWithRoleNotAllowed.Error(), respJSON["error"]) }, }, hs) } @@ -256,7 +257,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } @@ -287,12 +288,12 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CanAdminValue: true, CheckPermissionBeforeUpdateValue: true, GetACLValue: []*dashboards.DashboardACLInfoDTO{ - {OrgID: 1, DashboardID: 1, UserID: 2, UserLogin: "hiddenUser", Permission: models.PERMISSION_VIEW}, - {OrgID: 1, DashboardID: 1, UserID: 3, UserLogin: testUserLogin, Permission: models.PERMISSION_EDIT}, - {OrgID: 1, DashboardID: 1, UserID: 4, UserLogin: "user_1", Permission: models.PERMISSION_ADMIN}, + {OrgID: 1, DashboardID: 1, UserID: 2, UserLogin: "hiddenUser", Permission: dashboards.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 3, UserLogin: testUserLogin, Permission: dashboards.PERMISSION_EDIT}, + {OrgID: 1, DashboardID: 1, UserID: 4, UserLogin: "user_1", Permission: dashboards.PERMISSION_ADMIN}, }, GetHiddenACLValue: []*dashboards.DashboardACL{ - {OrgID: 1, DashboardID: 1, UserID: 2, Permission: models.PERMISSION_VIEW}, + {OrgID: 1, DashboardID: 1, UserID: 2, Permission: dashboards.PERMISSION_VIEW}, }, }) @@ -314,14 +315,14 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 2) assert.Equal(t, int64(3), resp[0].UserID) - assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission) + assert.Equal(t, dashboards.PERMISSION_EDIT, resp[0].Permission) assert.Equal(t, int64(4), resp[1].UserID) - assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission) + assert.Equal(t, dashboards.PERMISSION_ADMIN, resp[1].Permission) }, mockSQLStore) cmd := dtos.UpdateDashboardACLCommand{ Items: []dtos.DashboardACLUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, + {UserID: 1000, Permission: dashboards.PERMISSION_ADMIN}, }, } for _, acl := range resp { @@ -360,7 +361,7 @@ func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, h t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { sc := setupScenarioContext(t, ctx.url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 620e8c53659..8e7034c0844 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -34,8 +35,8 @@ func TestFoldersAPIEndpoint(t *testing.T) { folderService := &foldertest.FakeService{} t.Run("Given a correct request for creating a folder", func(t *testing.T) { - cmd := models.CreateFolderCommand{ - Uid: "uid", + cmd := folder.CreateFolderCommand{ + UID: "uid", Title: "Folder", } @@ -73,8 +74,8 @@ func TestFoldersAPIEndpoint(t *testing.T) { {Error: dashboards.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, } - cmd := models.CreateFolderCommand{ - Uid: "uid", + cmd := folder.CreateFolderCommand{ + UID: "uid", Title: "Folder", } @@ -235,23 +236,16 @@ func callCreateFolder(sc *scenarioContext) { } func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService folder.Service, - cmd models.CreateFolderCommand, fn scenarioFunc) { + cmd folder.CreateFolderCommand, fn scenarioFunc) { setUpRBACGuardian(t) t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { aclMockResp := []*dashboards.DashboardACLInfoDTO{} teamSvc := &teamtest.FakeService{} dashSvc := &dashboards.FakeDashboardService{} - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = aclMockResp - }).Return(nil) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: q.ID, - UID: q.UID, - } - }).Return(nil) + qResult1 := aclMockResp + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult1, nil) + qResult := &dashboards.Dashboard{} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) store := dbtest.NewFakeDB() guardian.InitLegacyGuardian(store, dashSvc, teamSvc) hs := HTTPServer{ @@ -263,7 +257,7 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -293,7 +287,7 @@ func updateFolderScenario(t *testing.T, desc string, url string, routePattern st } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/frontend_logging_test.go b/pkg/api/frontend_logging_test.go index 81f64a50223..563ae9ecd7a 100644 --- a/pkg/api/frontend_logging_test.go +++ b/pkg/api/frontend_logging_test.go @@ -15,8 +15,8 @@ import ( "github.com/grafana/grafana/pkg/api/frontendlogging" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" @@ -89,7 +89,7 @@ func logSentryEventScenario(t *testing.T, desc string, event frontendlogging.Fro loggingHandler := NewFrontendLogMessageHandler(sourceMapStore) - handler := routing.Wrap(func(c *models.ReqContext) response.Response { + handler := routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c c.Req.Body = mockRequestBody(event) c.Req.Header.Add("Content-Type", "application/json") @@ -162,7 +162,7 @@ func logGrafanaJavascriptAgentEventScenario(t *testing.T, desc string, event fro loggingHandler := GrafanaJavascriptAgentLogMessageHandler(sourceMapStore) - handler := routing.Wrap(func(c *models.ReqContext) response.Response { + handler := routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c c.Req.Body = mockRequestBody(event) c.Req.Header.Add("Content-Type", "application/json") diff --git a/pkg/api/frontend_metrics.go b/pkg/api/frontend_metrics.go index 6c6901786d2..bb5f35e0ce3 100644 --- a/pkg/api/frontend_metrics.go +++ b/pkg/api/frontend_metrics.go @@ -5,11 +5,11 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) PostFrontendMetrics(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PostFrontendMetrics(c *contextmodel.ReqContext) response.Response { cmd := metrics.PostFrontendMetricsCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index a67ab5b46ef..791cc38a893 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -2,12 +2,13 @@ package api import ( "context" + "fmt" "net/http" "strconv" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/querycaching" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -19,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func (hs *HTTPServer) GetFrontendSettings(c *models.ReqContext) { +func (hs *HTTPServer) GetFrontendSettings(c *contextmodel.ReqContext) { settings, err := hs.getFrontendSettingsMap(c) if err != nil { c.JsonApiErr(400, "Failed to get frontend settings", err) @@ -30,7 +31,7 @@ func (hs *HTTPServer) GetFrontendSettings(c *models.ReqContext) { } // getFrontendSettingsMap returns a json object with all the settings needed for front end initialisation. -func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]interface{}, error) { +func (hs *HTTPServer) getFrontendSettingsMap(c *contextmodel.ReqContext) (map[string]interface{}, error) { enabledPlugins, err := hs.enabledPlugins(c.Req.Context(), c.OrgID) if err != nil { return nil, err @@ -149,9 +150,11 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "OAuthSkipOrgRoleUpdateSync": hs.Cfg.OAuthSkipOrgRoleUpdateSync, "SAMLSkipOrgRoleSync": hs.Cfg.SectionWithEnvOverrides("auth.saml").Key("skip_org_role_sync").MustBool(false), "LDAPSkipOrgRoleSync": hs.Cfg.LDAPSkipOrgRoleSync, + "GithubSkipOrgRoleSync": hs.Cfg.GithubSkipOrgRoleSync, "GoogleSkipOrgRoleSync": hs.Cfg.GoogleSkipOrgRoleSync, "JWTAuthSkipOrgRoleSync": hs.Cfg.JWTAuthSkipOrgRoleSync, "GrafanaComSkipOrgRoleSync": hs.Cfg.GrafanaComSkipOrgRoleSync, + "GitLabSkipOrgRoleSync": hs.Cfg.GitLabSkipOrgRoleSync, "AzureADSkipOrgRoleSync": hs.Cfg.AzureADSkipOrgRoleSync, "DisableSyncLock": hs.Cfg.DisableSyncLock, }, @@ -208,6 +211,15 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "samlEnabled": hs.samlEnabled(), "samlName": hs.samlName(), "tokenExpirationDayLimit": hs.Cfg.SATokenExpirationDayLimit, + "snapshotEnabled": hs.Cfg.SnapshotEnabled, + } + + if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() { + cdnBaseURL, err := hs.pluginsCDNService.BaseURL() + if err != nil { + return nil, fmt.Errorf("plugins cdn base url: %w", err) + } + jsonObj["pluginsCDNBaseURL"] = cdnBaseURL } if hs.ThumbService != nil { @@ -229,7 +241,7 @@ func isSupportBundlesEnabled(hs *HTTPServer) bool { hs.Features.IsEnabled(featuremgmt.FlagSupportBundles) } -func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins EnabledPlugins) (map[string]plugins.DataSourceDTO, error) { +func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, enabledPlugins EnabledPlugins) (map[string]plugins.DataSourceDTO, error) { orgDataSources := make([]*datasources.DataSource, 0) if c.OrgID != 0 { query := datasources.GetDataSourcesQuery{OrgId: c.OrgID, DataSourceLimit: hs.Cfg.DataSourceLimit} diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index f004e6168fa..697c3a4b3cd 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -53,12 +55,16 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. Cfg: cfg, RendererPluginManager: &fakeRendererManager{}, }, - SQLStore: sqlStore, - SettingsProvider: setting.ProvideProvider(cfg), - pluginStore: &plugins.FakePluginStore{}, - grafanaUpdateChecker: &updatechecker.GrafanaService{}, - AccessControl: accesscontrolmock.New().WithDisabled(), - PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), + SQLStore: sqlStore, + SettingsProvider: setting.ProvideProvider(cfg), + pluginStore: &plugins.FakePluginStore{}, + grafanaUpdateChecker: &updatechecker.GrafanaService{}, + AccessControl: accesscontrolmock.New().WithDisabled(), + PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), + pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{ + PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, + PluginSettings: cfg.PluginSettings, + }), SocialService: social.ProvideService(cfg, features), DataSourceCacheCfgService: &querycaching.OSSDatasourceCacheConfigImpl{}, } @@ -140,3 +146,53 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { }) } } + +func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) { + type settings struct { + PluginsCDNBaseURL string `json:"pluginsCDNBaseURL"` + } + + tests := []struct { + desc string + mutateCfg func(*setting.Cfg) + expected settings + }{ + { + desc: "With CDN", + mutateCfg: func(cfg *setting.Cfg) { + cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}" + }, + expected: settings{PluginsCDNBaseURL: "https://cdn.example.com"}, + }, + { + desc: "Without CDN", + mutateCfg: func(cfg *setting.Cfg) { + cfg.PluginsCDNURLTemplate = "" + }, + expected: settings{PluginsCDNBaseURL: ""}, + }, + { + desc: "CDN is disabled by default", + expected: settings{PluginsCDNBaseURL: ""}, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + cfg := setting.NewCfg() + if test.mutateCfg != nil { + test.mutateCfg(cfg) + } + m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures()) + req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) + + recorder := httptest.NewRecorder() + m.ServeHTTP(recorder, req) + var got settings + err := json.Unmarshal(recorder.Body.Bytes(), &got) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.EqualValues(t, test.expected, got) + }) + } +} diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go index db974883d49..088b745b82f 100644 --- a/pkg/api/grafana_com_proxy.go +++ b/pkg/api/grafana_com_proxy.go @@ -8,8 +8,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/web" @@ -24,15 +23,15 @@ var grafanaComProxyTransport = &http.Transport{ TLSHandshakeTimeout: 10 * time.Second, } -func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string) *httputil.ReverseProxy { - url, _ := url.Parse(setting.GrafanaComUrl) +func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string, grafanaComAPIUrl string) *httputil.ReverseProxy { + url, _ := url.Parse(grafanaComAPIUrl) director := func(req *http.Request) { req.URL.Scheme = url.Scheme req.URL.Host = url.Host req.Host = url.Host - req.URL.Path = util.JoinURLFragments(url.Path+"/api", proxyPath) + req.URL.Path = util.JoinURLFragments(url.Path, proxyPath) // clear cookie headers req.Header.Del("Cookie") @@ -46,9 +45,9 @@ func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string) *h return proxyutil.NewReverseProxy(logger, director) } -func (hs *HTTPServer) ProxyGnetRequest(c *models.ReqContext) { +func (hs *HTTPServer) ProxyGnetRequest(c *contextmodel.ReqContext) { proxyPath := web.Params(c.Req)["*"] - proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion) + proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion, hs.Cfg.GrafanaComAPIURL) proxy.Transport = grafanaComProxyTransport proxy.ServeHTTP(c.Resp, c.Req) } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 233dcde5d0c..1e5f0ce61ed 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -33,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/middleware/csrf" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/registry/corekind" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" @@ -90,6 +91,7 @@ import ( "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/star" + starApi "github.com/grafana/grafana/pkg/services/star/api" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity/httpentitystore" @@ -202,6 +204,7 @@ type HTTPServer struct { playlistService playlist.Service apiKeyService apikey.Service kvStore kvstore.KVStore + pluginsCDNService *pluginscdn.Service userService user.Service tempUserService tempUser.Service @@ -215,6 +218,7 @@ type HTTPServer struct { oauthTokenService oauthtoken.OAuthTokenService statsService stats.Service authnService authn.Service + starApi *starApi.API } type ServerOptions struct { @@ -257,8 +261,9 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi accesscontrolService accesscontrol.Service, dashboardThumbsService thumbs.DashboardThumbService, navTreeService navtree.Service, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService, - statsService stats.Service, authnService authn.Service, dataSourceCacheCfgService querycaching.DatasourceCacheConfig, + statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, dataSourceCacheCfgService querycaching.DatasourceCacheConfig, k8saccess k8saccess.K8SAccess, // required so that the router is registered + starApi *starApi.API, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -366,6 +371,8 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi oauthTokenService: oauthTokenService, statsService: statsService, authnService: authnService, + pluginsCDNService: pluginsCDNService, + starApi: starApi, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/api/index.go b/pkg/api/index.go index dbf621190ef..a8b5888c2b6 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -6,10 +6,11 @@ import ( "strings" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/setting" ) @@ -20,15 +21,16 @@ const ( darkName = "dark" ) -func (hs *HTTPServer) editorInAnyFolder(c *models.ReqContext) bool { - hasEditPermissionInFoldersQuery := models.HasEditPermissionInFoldersQuery{SignedInUser: c.SignedInUser} - if err := hs.DashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery); err != nil { +func (hs *HTTPServer) editorInAnyFolder(c *contextmodel.ReqContext) bool { + hasEditPermissionInFoldersQuery := folder.HasEditPermissionInFoldersQuery{SignedInUser: c.SignedInUser} + hasEditPermissionInFoldersQueryResult, err := hs.DashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery) + if err != nil { return false } - return hasEditPermissionInFoldersQuery.Result + return hasEditPermissionInFoldersQueryResult } -func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewData, error) { +func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexViewData, error) { hasAccess := ac.HasAccess(hs.AccessControl, c) hasEditPerm := hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(dashboards.ActionDashboardsCreate), ac.EvalPermission(dashboards.ActionFoldersCreate))) @@ -165,7 +167,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat return &data, nil } -func (hs *HTTPServer) Index(c *models.ReqContext) { +func (hs *HTTPServer) Index(c *contextmodel.ReqContext) { data, err := hs.setIndexViewData(c) if err != nil { c.Handle(hs.Cfg, 500, "Failed to get settings", err) @@ -174,7 +176,7 @@ func (hs *HTTPServer) Index(c *models.ReqContext) { c.HTML(http.StatusOK, "index", data) } -func (hs *HTTPServer) NotFoundHandler(c *models.ReqContext) { +func (hs *HTTPServer) NotFoundHandler(c *contextmodel.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(404, "Not found", nil) return diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index 875e838f8f8..a6907ad7b93 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" @@ -47,14 +47,14 @@ type LDAPRoleDTO struct { // LDAPUserDTO is a serializer for users mapped from LDAP type LDAPUserDTO struct { - Name *LDAPAttribute `json:"name"` - Surname *LDAPAttribute `json:"surname"` - Email *LDAPAttribute `json:"email"` - Username *LDAPAttribute `json:"login"` - IsGrafanaAdmin *bool `json:"isGrafanaAdmin"` - IsDisabled bool `json:"isDisabled"` - OrgRoles []LDAPRoleDTO `json:"roles"` - Teams []models.TeamOrgGroupDTO `json:"teams"` + Name *LDAPAttribute `json:"name"` + Surname *LDAPAttribute `json:"surname"` + Email *LDAPAttribute `json:"email"` + Username *LDAPAttribute `json:"login"` + IsGrafanaAdmin *bool `json:"isGrafanaAdmin"` + IsDisabled bool `json:"isDisabled"` + OrgRoles []LDAPRoleDTO `json:"roles"` + Teams []ldap.TeamOrgGroupDTO `json:"teams"` } // LDAPServerDTO is a serializer for LDAP server statuses @@ -117,7 +117,7 @@ func (user *LDAPUserDTO) FetchOrgs(ctx context.Context, orga org.Service) error // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) ReloadLDAPCfg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ReloadLDAPCfg(c *contextmodel.ReqContext) response.Response { if !ldap.IsEnabled() { return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -143,7 +143,7 @@ func (hs *HTTPServer) ReloadLDAPCfg(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetLDAPStatus(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetLDAPStatus(c *contextmodel.ReqContext) response.Response { if !ldap.IsEnabled() { return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -196,7 +196,7 @@ func (hs *HTTPServer) GetLDAPStatus(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PostSyncUserWithLDAP(c *contextmodel.ReqContext) response.Response { if !ldap.IsEnabled() { return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -222,7 +222,7 @@ func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Respon return response.Error(500, "Failed to get user", err) } - authModuleQuery := &models.GetAuthInfoQuery{UserId: usr.ID, AuthModule: login.LDAPAuthModule} + authModuleQuery := &login.GetAuthInfoQuery{UserId: usr.ID, AuthModule: login.LDAPAuthModule} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), authModuleQuery); err != nil { // validate the userId comes from LDAP if errors.Is(err, user.ErrUserNotFound) { return response.Error(404, user.ErrUserNotFound.Error(), nil) @@ -259,11 +259,11 @@ func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Respon return response.Error(http.StatusBadRequest, "Something went wrong while finding the user in LDAP", err) } - upsertCmd := &models.UpsertUserCommand{ + upsertCmd := &login.UpsertUserCommand{ ReqContext: c, ExternalUser: userInfo, SignupAllowed: hs.Cfg.LDAPAllowSignup, - UserLookupParams: models.UserLookupParams{ + UserLookupParams: login.UserLookupParams{ UserID: &usr.ID, // Upsert by ID only Email: nil, Login: nil, @@ -292,7 +292,7 @@ func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Respon // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetUserFromLDAP(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserFromLDAP(c *contextmodel.ReqContext) response.Response { if !ldap.IsEnabled() { return response.Error(http.StatusBadRequest, "LDAP is not enabled", nil) } diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index e95a4bbeb5c..5e4e51fd750 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -14,10 +14,11 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/authtest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/multildap" @@ -30,10 +31,10 @@ import ( ) type LDAPMock struct { - Results []*models.ExternalUserInfo + Results []*login.ExternalUserInfo } -var userSearchResult *models.ExternalUserInfo +var userSearchResult *login.ExternalUserInfo var userSearchConfig ldap.ServerConfig var userSearchError error var pingResult []*multildap.ServerStatus @@ -43,16 +44,16 @@ func (m *LDAPMock) Ping() ([]*multildap.ServerStatus, error) { return pingResult, pingError } -func (m *LDAPMock) Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) { - return &models.ExternalUserInfo{}, nil +func (m *LDAPMock) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) { + return &login.ExternalUserInfo{}, nil } -func (m *LDAPMock) Users(logins []string) ([]*models.ExternalUserInfo, error) { - s := []*models.ExternalUserInfo{} +func (m *LDAPMock) Users(logins []string) ([]*login.ExternalUserInfo, error) { + s := []*login.ExternalUserInfo{} return s, nil } -func (m *LDAPMock) User(login string) (*models.ExternalUserInfo, ldap.ServerConfig, error) { +func (m *LDAPMock) User(login string) (*login.ExternalUserInfo, ldap.ServerConfig, error) { return userSearchResult, userSearchConfig, userSearchError } @@ -71,7 +72,7 @@ func getUserFromLDAPContext(t *testing.T, requestURL string, searchOrgRst []*org hs := &HTTPServer{Cfg: setting.NewCfg(), ldapGroups: ldap.ProvideGroupsService(), orgService: &orgtest.FakeOrgService{ExpectedOrgs: searchOrgRst}} - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c return hs.GetUserFromLDAP(c) }) @@ -105,7 +106,7 @@ func TestGetUserFromLDAPAPIEndpoint_UserNotFound(t *testing.T) { func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) { isAdmin := true - userSearchResult = &models.ExternalUserInfo{ + userSearchResult = &login.ExternalUserInfo{ Name: "John Doe", Email: "john.doe@example.com", Login: "johndoe", @@ -160,7 +161,7 @@ func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) { func TestGetUserFromLDAPAPIEndpoint(t *testing.T) { isAdmin := true - userSearchResult = &models.ExternalUserInfo{ + userSearchResult = &login.ExternalUserInfo{ Name: "John Doe", Email: "john.doe@example.com", Login: "johndoe", @@ -235,7 +236,7 @@ func TestGetUserFromLDAPAPIEndpoint(t *testing.T) { func TestGetUserFromLDAPAPIEndpoint_WithTeamHandler(t *testing.T) { isAdmin := true - userSearchResult = &models.ExternalUserInfo{ + userSearchResult = &login.ExternalUserInfo{ Name: "John Doe", Email: "john.doe@example.com", Login: "johndoe", @@ -318,7 +319,7 @@ func getLDAPStatusContext(t *testing.T) *scenarioContext { hs := &HTTPServer{Cfg: setting.NewCfg()} - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c return hs.GetLDAPStatus(c) }) @@ -386,7 +387,7 @@ func postSyncUserWithLDAPContext(t *testing.T, requestURL string, preHook func(* userService: userService, } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c return hs.PostSyncUserWithLDAP(c) }) @@ -417,7 +418,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_Success(t *testing.T) { return &LDAPMock{} } - userSearchResult = &models.ExternalUserInfo{ + userSearchResult = &login.ExternalUserInfo{ Login: "ldap-daniel", } }, userServiceMock) @@ -486,7 +487,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { userServiceMock := usertest.NewUserServiceFake() userServiceMock.ExpectedUser = &user.User{Login: "ldap-daniel", ID: 34} sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34", func(t *testing.T, sc *scenarioContext) { - sc.authInfoService.ExpectedExternalUser = &models.ExternalUserInfo{IsDisabled: true, UserId: 34} + sc.authInfoService.ExpectedExternalUser = &login.ExternalUserInfo{IsDisabled: true, UserId: 34} getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) { return &ldap.Config{}, nil } @@ -624,7 +625,7 @@ func TestLDAP_AccessControl(t *testing.T) { hs.authInfoService = &logintest.AuthInfoServiceFake{} }) // Add minimal setup to pass handler - userSearchResult = &models.ExternalUserInfo{} + userSearchResult = &login.ExternalUserInfo{} userSearchError = nil newLDAP = func(_ []*ldap.ServerConfig) multildap.IMultiLDAP { return &LDAPMock{} diff --git a/pkg/api/login.go b/pkg/api/login.go index c2d81a77636..8d041941ff4 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -15,11 +15,11 @@ import ( "github.com/grafana/grafana/pkg/infra/network" "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" "github.com/grafana/grafana/pkg/services/authn" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" - loginService "github.com/grafana/grafana/pkg/services/login" + loginservice "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -80,7 +80,7 @@ func (hs *HTTPServer) CookieOptionsFromCfg() cookies.CookieOptions { } } -func (hs *HTTPServer) LoginView(c *models.ReqContext) { +func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { viewData, err := setIndexViewData(hs, c) if err != nil { c.Handle(hs.Cfg, 500, "Failed to get settings", err) @@ -139,7 +139,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { c.HTML(http.StatusOK, getViewIndex(), viewData) } -func (hs *HTTPServer) tryAutoLogin(c *models.ReqContext) bool { +func (hs *HTTPServer) tryAutoLogin(c *contextmodel.ReqContext) bool { samlAutoLogin := hs.samlAutoLoginEnabled() oauthInfos := hs.SocialService.GetOAuthInfoProviders() @@ -185,7 +185,7 @@ func (hs *HTTPServer) tryAutoLogin(c *models.ReqContext) bool { return false } -func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) response.Response { +func (hs *HTTPServer) LoginAPIPing(c *contextmodel.ReqContext) response.Response { if c.IsSignedIn || c.IsAnonymous { return response.JSON(http.StatusOK, "Logged in") } @@ -193,7 +193,7 @@ func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) response.Response { return response.Error(401, "Unauthorized", nil) } -func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { +func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response { if hs.Features.IsEnabled(featuremgmt.FlagAuthnService) { identity, err := hs.authnService.Login(c.Req.Context(), authn.ClientForm, &authn.Request{HTTPRequest: c.Req, Resp: c.Resp}) if err != nil { @@ -235,7 +235,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { if err == nil && resp.ErrMessage() != "" { err = errors.New(resp.ErrMessage()) } - hs.HooksService.RunLoginHook(&models.LoginInfo{ + hs.HooksService.RunLoginHook(&loginservice.LoginInfo{ AuthModule: authModule, User: usr, LoginUsername: cmd.User, @@ -249,7 +249,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { return resp } - authQuery := &models.LoginUserQuery{ + authQuery := &loginservice.LoginUserQuery{ ReqContext: c, Username: cmd.User, Password: cmd.Password, @@ -313,7 +313,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { return resp } -func (hs *HTTPServer) loginUserWithUser(user *user.User, c *models.ReqContext) error { +func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error { if user == nil { return errors.New("could not login user") } @@ -326,7 +326,7 @@ func (hs *HTTPServer) loginUserWithUser(user *user.User, c *models.ReqContext) e } hs.log.Debug("Got IP address from client address", "addr", addr, "ip", ip) - ctx := context.WithValue(c.Req.Context(), models.RequestURIKey{}, c.Req.RequestURI) + ctx := context.WithValue(c.Req.Context(), loginservice.RequestURIKey{}, c.Req.RequestURI) userToken, err := hs.AuthTokenService.CreateToken(ctx, user, ip, c.Req.UserAgent()) if err != nil { return fmt.Errorf("%v: %w", "failed to create auth token", err) @@ -338,12 +338,12 @@ func (hs *HTTPServer) loginUserWithUser(user *user.User, c *models.ReqContext) e return nil } -func (hs *HTTPServer) Logout(c *models.ReqContext) { +func (hs *HTTPServer) Logout(c *contextmodel.ReqContext) { // If SAML is enabled and this is a SAML user use saml logout if hs.samlSingleLogoutEnabled() { - getAuthQuery := models.GetAuthInfoQuery{UserId: c.UserID} + getAuthQuery := loginservice.GetAuthInfoQuery{UserId: c.UserID} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &getAuthQuery); err == nil { - if getAuthQuery.Result.AuthModule == loginService.SAMLAuthModule { + if getAuthQuery.Result.AuthModule == loginservice.SAMLAuthModule { c.Redirect(hs.Cfg.AppSubURL + "/logout/saml") return } @@ -372,7 +372,7 @@ func (hs *HTTPServer) Logout(c *models.ReqContext) { } } -func (hs *HTTPServer) tryGetEncryptedCookie(ctx *models.ReqContext, cookieName string) (string, bool) { +func (hs *HTTPServer) tryGetEncryptedCookie(ctx *contextmodel.ReqContext, cookieName string) (string, bool) { cookie := ctx.GetCookie(cookieName) if cookie == "" { return "", false @@ -387,7 +387,7 @@ func (hs *HTTPServer) tryGetEncryptedCookie(ctx *models.ReqContext, cookieName s return string(decryptedError), err == nil } -func (hs *HTTPServer) trySetEncryptedCookie(ctx *models.ReqContext, cookieName string, value string, maxAge int) error { +func (hs *HTTPServer) trySetEncryptedCookie(ctx *contextmodel.ReqContext, cookieName string, value string, maxAge int) error { encryptedError, err := hs.SecretsService.Encrypt(ctx.Req.Context(), []byte(value), secrets.WithoutScope()) if err != nil { return err @@ -398,7 +398,7 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *models.ReqContext, cookieName s return nil } -func (hs *HTTPServer) redirectWithError(ctx *models.ReqContext, err error, v ...interface{}) { +func (hs *HTTPServer) redirectWithError(ctx *contextmodel.ReqContext, err error, v ...interface{}) { ctx.Logger.Warn(err.Error(), v...) if err := hs.trySetEncryptedCookie(ctx, loginErrorCookieName, getLoginExternalError(err), 60); err != nil { hs.log.Error("Failed to set encrypted cookie", "err", err) @@ -407,7 +407,7 @@ func (hs *HTTPServer) redirectWithError(ctx *models.ReqContext, err error, v ... ctx.Redirect(hs.Cfg.AppSubURL + "/login") } -func (hs *HTTPServer) RedirectResponseWithError(ctx *models.ReqContext, err error, v ...interface{}) *response.RedirectResponse { +func (hs *HTTPServer) RedirectResponseWithError(ctx *contextmodel.ReqContext, err error, v ...interface{}) *response.RedirectResponse { ctx.Logger.Error(err.Error(), v...) if err := hs.trySetEncryptedCookie(ctx, loginErrorCookieName, getLoginExternalError(err), 60); err != nil { hs.log.Error("Failed to set encrypted cookie", "err", err) diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 84bb08d7fe4..f444615081b 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -17,7 +17,8 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/middleware/cookies" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + loginservice "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -68,8 +69,8 @@ func genPKCECode() (string, string, error) { return string(ascii), pkce, nil } -func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { - loginInfo := models.LoginInfo{ +func (hs *HTTPServer) OAuthLogin(ctx *contextmodel.ReqContext) { + loginInfo := loginservice.LoginInfo{ AuthModule: "oauth", } name := web.Params(ctx.Req)[":name"] @@ -270,10 +271,10 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { } // buildExternalUserInfo returns a ExternalUserInfo struct from OAuth user profile -func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *social.BasicUserInfo, name string) *models.ExternalUserInfo { +func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *social.BasicUserInfo, name string) *loginservice.ExternalUserInfo { oauthLogger.Debug("Building external user info from OAuth user info") - extUser := &models.ExternalUserInfo{ + extUser := &loginservice.ExternalUserInfo{ AuthModule: fmt.Sprintf("oauth_%s", name), OAuthToken: token, AuthId: userInfo.Id, @@ -308,17 +309,17 @@ func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *socia // SyncUser syncs a Grafana user profile with the corresponding OAuth profile. func (hs *HTTPServer) SyncUser( - ctx *models.ReqContext, - extUser *models.ExternalUserInfo, + ctx *contextmodel.ReqContext, + extUser *loginservice.ExternalUserInfo, connect social.SocialConnector, ) (*user.User, error) { oauthLogger.Debug("Syncing Grafana user with corresponding OAuth profile") // add/update user in Grafana - cmd := &models.UpsertUserCommand{ + cmd := &loginservice.UpsertUserCommand{ ReqContext: ctx, ExternalUser: extUser, SignupAllowed: connect.IsSignupAllowed(), - UserLookupParams: models.UserLookupParams{ + UserLookupParams: loginservice.UserLookupParams{ Email: &extUser.Email, UserID: nil, Login: nil, @@ -350,7 +351,7 @@ type LoginError struct { Err error } -func (hs *HTTPServer) handleOAuthLoginError(ctx *models.ReqContext, info models.LoginInfo, err LoginError) { +func (hs *HTTPServer) handleOAuthLoginError(ctx *contextmodel.ReqContext, info loginservice.LoginInfo, err LoginError) { ctx.Handle(hs.Cfg, err.HttpStatus, err.PublicMessage, err.Err) info.Error = err.Err @@ -362,7 +363,7 @@ func (hs *HTTPServer) handleOAuthLoginError(ctx *models.ReqContext, info models. hs.HooksService.RunLoginHook(&info, ctx) } -func (hs *HTTPServer) handleOAuthLoginErrorWithRedirect(ctx *models.ReqContext, info models.LoginInfo, err error, v ...interface{}) { +func (hs *HTTPServer) handleOAuthLoginErrorWithRedirect(ctx *contextmodel.ReqContext, info loginservice.LoginInfo, err error, v ...interface{}) { hs.redirectWithError(ctx, err, v...) info.Error = err diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 9e09ba3deaa..402d9d77b7e 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -12,6 +12,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" @@ -19,8 +22,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "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/authtest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" @@ -31,8 +34,6 @@ import ( secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func fakeSetIndexViewData(t *testing.T) { @@ -40,7 +41,7 @@ func fakeSetIndexViewData(t *testing.T) { t.Cleanup(func() { setIndexViewData = origSetIndexViewData }) - setIndexViewData = func(*HTTPServer, *models.ReqContext) (*dtos.IndexViewData, error) { + setIndexViewData = func(*HTTPServer, *contextmodel.ReqContext) (*dtos.IndexViewData, error) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, @@ -104,7 +105,7 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) { SecretsService: secretsService, } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { hs.LoginView(c) return response.Empty(http.StatusOK) }) @@ -152,7 +153,7 @@ func TestLoginViewRedirect(t *testing.T) { } hs.Cfg.CookieSecure = true - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.IsSignedIn = true c.SignedInUser = &user.SignedInUser{ UserID: 10, @@ -328,7 +329,7 @@ func TestLoginPostRedirect(t *testing.T) { } hs.Cfg.CookieSecure = true - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Header.Set("Content-Type", "application/json") c.Req.Body = io.NopCloser(bytes.NewBufferString(`{"user":"admin","password":"admin"}`)) return hs.LoginPost(c) @@ -492,7 +493,7 @@ func TestLoginOAuthRedirect(t *testing.T) { SocialService: mock, } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { hs.LoginView(c) return response.Empty(http.StatusOK) }) @@ -518,7 +519,7 @@ func TestLoginInternal(t *testing.T) { log: log.New("test"), } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.URL.RawQuery = "disableAutoLogin=true" hs.LoginView(c) return response.Empty(http.StatusOK) @@ -570,7 +571,7 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte SocialService: &mockSocialService{}, } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.IsSignedIn = true c.SignedInUser = &user.SignedInUser{ UserID: 10, @@ -589,10 +590,10 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte } type loginHookTest struct { - info *models.LoginInfo + info *loginservice.LoginInfo } -func (r *loginHookTest) LoginHook(loginInfo *models.LoginInfo, req *models.ReqContext) { +func (r *loginHookTest) LoginHook(loginInfo *loginservice.LoginInfo, req *contextmodel.ReqContext) { r.info = loginInfo } @@ -608,7 +609,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { HooksService: hookService, } - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Header.Set("Content-Type", "application/json") c.Req.Body = io.NopCloser(bytes.NewBufferString(`{"user":"admin","password":"admin"}`)) x := hs.LoginPost(c) @@ -628,12 +629,12 @@ func TestLoginPostRunLokingHook(t *testing.T) { authUser *user.User authModule string authErr error - info models.LoginInfo + info loginservice.LoginInfo }{ { desc: "invalid credentials", authErr: login.ErrInvalidCredentials, - info: models.LoginInfo{ + info: loginservice.LoginInfo{ AuthModule: "", HTTPStatus: 401, Error: login.ErrInvalidCredentials, @@ -642,7 +643,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { { desc: "user disabled", authErr: login.ErrUserDisabled, - info: models.LoginInfo{ + info: loginservice.LoginInfo{ AuthModule: "", HTTPStatus: 401, Error: login.ErrUserDisabled, @@ -652,7 +653,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { desc: "valid Grafana user", authUser: testUser, authModule: "grafana", - info: models.LoginInfo{ + info: loginservice.LoginInfo{ AuthModule: "grafana", User: testUser, HTTPStatus: 200, @@ -662,7 +663,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { desc: "valid LDAP user", authUser: testUser, authModule: loginservice.LDAPAuthModule, - info: models.LoginInfo{ + info: loginservice.LoginInfo{ AuthModule: loginservice.LDAPAuthModule, User: testUser, HTTPStatus: 200, @@ -725,7 +726,7 @@ type fakeAuthenticator struct { ExpectedError error } -func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { +func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *loginservice.LoginUserQuery) error { query.User = fa.ExpectedUser query.AuthModule = fa.ExpectedAuthModule return fa.ExpectedError diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 6f74a46a3f9..695a28e21de 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" @@ -46,7 +46,7 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons // 400: badRequestError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext) response.Response { +func (hs *HTTPServer) QueryMetricsV2(c *contextmodel.ReqContext) response.Response { reqDTO := dtos.MetricRequest{} if err := web.Bind(c.Req, &reqDTO); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/openapi3.go b/pkg/api/openapi3.go index ad01675cf08..a5f3eb483a4 100644 --- a/pkg/api/openapi3.go +++ b/pkg/api/openapi3.go @@ -3,9 +3,9 @@ package api import ( "net/http" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) -func openapi3(c *models.ReqContext) { +func openapi3(c *contextmodel.ReqContext) { c.HTML(http.StatusOK, "openapi3", nil) } diff --git a/pkg/api/org.go b/pkg/api/org.go index 9030412153f..c4c2ea7a5d7 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -25,7 +25,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetCurrentOrg(c *contextmodel.ReqContext) response.Response { return hs.getOrgHelper(c.Req.Context(), c.OrgID) } @@ -41,7 +41,7 @@ func (hs *HTTPServer) GetCurrentOrg(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgByID(c *contextmodel.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) @@ -61,7 +61,7 @@ func (hs *HTTPServer) GetOrgByID(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgByName(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgByName(c *contextmodel.ReqContext) response.Response { orga, err := hs.orgService.GetByName(c.Req.Context(), &org.GetOrgByNameQuery{Name: web.Params(c.Req)[":name"]}) if err != nil { if errors.Is(err, org.ErrOrgNotFound) { @@ -126,7 +126,7 @@ func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Re // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) CreateOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreateOrg(c *contextmodel.ReqContext) response.Response { cmd := org.CreateOrgCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -163,7 +163,7 @@ func (hs *HTTPServer) CreateOrg(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateCurrentOrg(c *contextmodel.ReqContext) response.Response { form := dtos.UpdateOrgForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -184,7 +184,7 @@ func (hs *HTTPServer) UpdateCurrentOrg(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrg(c *contextmodel.ReqContext) response.Response { form := dtos.UpdateOrgForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -218,7 +218,7 @@ func (hs *HTTPServer) updateOrgHelper(ctx context.Context, form dtos.UpdateOrgFo // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateCurrentOrgAddress(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateCurrentOrgAddress(c *contextmodel.ReqContext) response.Response { form := dtos.UpdateOrgAddressForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -236,7 +236,7 @@ func (hs *HTTPServer) UpdateCurrentOrgAddress(c *models.ReqContext) response.Res // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateOrgAddress(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrgAddress(c *contextmodel.ReqContext) response.Response { form := dtos.UpdateOrgAddressForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -282,7 +282,7 @@ func (hs *HTTPServer) updateOrgAddressHelper(ctx context.Context, form dtos.Upda // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteOrgByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteOrgByID(c *contextmodel.ReqContext) response.Response { orgID, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) @@ -314,7 +314,7 @@ func (hs *HTTPServer) DeleteOrgByID(c *models.ReqContext) response.Response { // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) SearchOrgs(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchOrgs(c *contextmodel.ReqContext) response.Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 30271ed2f55..855c26c2922 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/org" tempuser "github.com/grafana/grafana/pkg/services/temp_user" @@ -32,7 +32,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetPendingOrgInvites(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPendingOrgInvites(c *contextmodel.ReqContext) response.Response { query := tempuser.GetTempUsersQuery{OrgID: c.OrgID, Status: tempuser.TmpUserInvitePending} queryResult, err := hs.tempUserService.GetTempUsersQuery(c.Req.Context(), &query) @@ -58,7 +58,7 @@ func (hs *HTTPServer) GetPendingOrgInvites(c *models.ReqContext) response.Respon // 403: forbiddenError // 412: SMTPNotEnabledError // 500: internalServerError -func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddOrgInvite(c *contextmodel.ReqContext) response.Response { inviteDto := dtos.AddInviteForm{} if err := web.Bind(c.Req, &inviteDto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -145,7 +145,7 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response { return response.Success(fmt.Sprintf("Created invite for %s", inviteDto.LoginOrEmail)) } -func (hs *HTTPServer) inviteExistingUserToOrg(c *models.ReqContext, user *user.User, inviteDto *dtos.AddInviteForm) response.Response { +func (hs *HTTPServer) inviteExistingUserToOrg(c *contextmodel.ReqContext, user *user.User, inviteDto *dtos.AddInviteForm) response.Response { // user exists, add org role createOrgUserCmd := org.AddOrgUserCommand{OrgID: c.OrgID, UserID: user.ID, Role: inviteDto.Role} if err := hs.orgService.AddOrgUser(c.Req.Context(), &createOrgUserCmd); err != nil { @@ -187,7 +187,7 @@ func (hs *HTTPServer) inviteExistingUserToOrg(c *models.ReqContext, user *user.U // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) RevokeInvite(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RevokeInvite(c *contextmodel.ReqContext) response.Response { if ok, rsp := hs.updateTempUserStatus(c.Req.Context(), web.Params(c.Req)[":code"], tempuser.TmpUserRevoked); !ok { return rsp } @@ -198,7 +198,7 @@ func (hs *HTTPServer) RevokeInvite(c *models.ReqContext) response.Response { // GetInviteInfoByCode gets a pending user invite corresponding to a certain code. // A response containing an InviteInfo object is returned if the invite is found. // If a (pending) invite is not found, 404 is returned. -func (hs *HTTPServer) GetInviteInfoByCode(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetInviteInfoByCode(c *contextmodel.ReqContext) response.Response { query := tempuser.GetTempUserByCodeQuery{Code: web.Params(c.Req)[":code"]} queryResult, err := hs.tempUserService.GetTempUserByCode(c.Req.Context(), &query) if err != nil { @@ -221,7 +221,7 @@ func (hs *HTTPServer) GetInviteInfoByCode(c *models.ReqContext) response.Respons }) } -func (hs *HTTPServer) CompleteInvite(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CompleteInvite(c *contextmodel.ReqContext) response.Response { completeInvite := dtos.CompleteInviteForm{} var err error if err = web.Bind(c.Req, &completeInvite); err != nil { diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index a3151f2936b..b0c7c807cc0 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -9,8 +9,8 @@ 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/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -32,7 +32,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AddOrgUserToCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddOrgUserToCurrentOrg(c *contextmodel.ReqContext) response.Response { cmd := org.AddOrgUserCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -55,7 +55,7 @@ func (hs *HTTPServer) AddOrgUserToCurrentOrg(c *models.ReqContext) response.Resp // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) AddOrgUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddOrgUser(c *contextmodel.ReqContext) response.Response { cmd := org.AddOrgUserCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -69,7 +69,7 @@ func (hs *HTTPServer) AddOrgUser(c *models.ReqContext) response.Response { return hs.addOrgUserHelper(c, cmd) } -func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd org.AddOrgUserCommand) response.Response { +func (hs *HTTPServer) addOrgUserHelper(c *contextmodel.ReqContext, cmd org.AddOrgUserCommand) response.Response { if !cmd.Role.IsValid() { return response.Error(400, "Invalid role specified", nil) } @@ -114,7 +114,7 @@ func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd org.AddOrgUserC // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *contextmodel.ReqContext) response.Response { result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{ OrgID: c.OrgID, Query: c.Query("query"), @@ -143,7 +143,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *contextmodel.ReqContext) response.Response { orgUsersResult, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{ OrgID: c.OrgID, Query: c.Query("query"), @@ -184,7 +184,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgUsers(c *contextmodel.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) @@ -219,7 +219,7 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) SearchOrgUsers(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchOrgUsers(c *contextmodel.ReqContext) response.Response { orgID, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) @@ -252,7 +252,7 @@ func (hs *HTTPServer) SearchOrgUsers(c *models.ReqContext) response.Response { // SearchOrgUsersWithPaging is an HTTP handler to search for org users with paging. // GET /api/org/users/search -func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchOrgUsersWithPaging(c *contextmodel.ReqContext) response.Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -279,7 +279,7 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Re return response.JSON(http.StatusOK, result) } -func (hs *HTTPServer) searchOrgUsersHelper(c *models.ReqContext, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { +func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { result, err := hs.orgService.SearchOrgUsers(c.Req.Context(), query) if err != nil { return nil, err @@ -299,7 +299,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *models.ReqContext, query *org.Sear filteredUsers = append(filteredUsers, user) } - modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), models.GetUserLabelsQuery{ + modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), login.GetUserLabelsQuery{ UserIDs: authLabelsUserIDs, }) @@ -335,7 +335,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *models.ReqContext, query *org.Sear // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateOrgUserForCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrgUserForCurrentOrg(c *contextmodel.ReqContext) response.Response { cmd := org.UpdateOrgUserCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -362,7 +362,7 @@ func (hs *HTTPServer) UpdateOrgUserForCurrentOrg(c *models.ReqContext) response. // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateOrgUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrgUser(c *contextmodel.ReqContext) response.Response { cmd := org.UpdateOrgUserCommand{} var err error if err := web.Bind(c.Req, &cmd); err != nil { @@ -379,7 +379,7 @@ func (hs *HTTPServer) UpdateOrgUser(c *models.ReqContext) response.Response { return hs.updateOrgUserHelper(c, cmd) } -func (hs *HTTPServer) updateOrgUserHelper(c *models.ReqContext, cmd org.UpdateOrgUserCommand) response.Response { +func (hs *HTTPServer) updateOrgUserHelper(c *contextmodel.ReqContext, cmd org.UpdateOrgUserCommand) response.Response { if !cmd.Role.IsValid() { return response.Error(400, "Invalid role specified", nil) } @@ -409,7 +409,7 @@ func (hs *HTTPServer) updateOrgUserHelper(c *models.ReqContext, cmd org.UpdateOr // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) RemoveOrgUserForCurrentOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RemoveOrgUserForCurrentOrg(c *contextmodel.ReqContext) response.Response { userId, err := strconv.ParseInt(web.Params(c.Req)[":userId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "userId is invalid", err) @@ -435,7 +435,7 @@ func (hs *HTTPServer) RemoveOrgUserForCurrentOrg(c *models.ReqContext) response. // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) RemoveOrgUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RemoveOrgUser(c *contextmodel.ReqContext) response.Response { userId, err := strconv.ParseInt(web.Params(c.Req)[":userId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "userId is invalid", err) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 5b704ee8bb7..47902f5045d 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -224,7 +223,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_FolderAdmin(t *testing.T) { DashboardID: folder.ID, OrgID: testOrgID, UserID: testUserID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: time.Now(), Updated: time.Now(), }, @@ -245,7 +244,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_TeamAdmin(t *testing.T) { // Setup store teams team1, err := sc.teamService.CreateTeam("testteam1", "testteam1@example.org", testOrgID) require.NoError(t, err) - err = sc.teamService.AddTeamMember(testUserID, testOrgID, team1.ID, false, models.PERMISSION_ADMIN) + err = sc.teamService.AddTeamMember(testUserID, testOrgID, team1.ID, false, dashboards.PERMISSION_ADMIN) require.NoError(t, err) response := callAPI(sc.server, http.MethodGet, "/api/org/users/lookup", nil, t) diff --git a/pkg/api/password.go b/pkg/api/password.go index 5d60de08254..80753bad9c7 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/user" @@ -16,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SendResetPasswordEmail(c *contextmodel.ReqContext) response.Response { form := dtos.SendResetPasswordEmailForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -38,7 +39,7 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Resp return response.Error(http.StatusOK, "Email sent", nil) } - getAuthQuery := models.GetAuthInfoQuery{UserId: usr.ID} + getAuthQuery := login.GetAuthInfoQuery{UserId: usr.ID} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &getAuthQuery); err == nil { authModule := getAuthQuery.Result.AuthModule if authModule == login.LDAPAuthModule || authModule == login.AuthProxyAuthModule { @@ -54,7 +55,7 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Resp return response.Success("Email sent") } -func (hs *HTTPServer) ResetPassword(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ResetPassword(c *contextmodel.ReqContext) response.Response { form := dtos.ResetUserPasswordForm{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index e37f4d5c577..b9303bff7b3 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -5,12 +5,12 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) ValidateOrgPlaylist(c *models.ReqContext) { +func (hs *HTTPServer) ValidateOrgPlaylist(c *contextmodel.ReqContext) { uid := web.Params(c.Req)[":uid"] query := playlist.GetPlaylistByUidQuery{UID: uid, OrgId: c.OrgID} p, err := hs.playlistService.GetWithoutItems(c.Req.Context(), &query) @@ -38,7 +38,7 @@ func (hs *HTTPServer) ValidateOrgPlaylist(c *models.ReqContext) { // Responses: // 200: searchPlaylistsResponse // 500: internalServerError -func (hs *HTTPServer) SearchPlaylists(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchPlaylists(c *contextmodel.ReqContext) response.Response { query := c.Query("query") limit := c.QueryInt("limit") @@ -70,7 +70,7 @@ func (hs *HTTPServer) SearchPlaylists(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPlaylist(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] cmd := playlist.GetPlaylistByUidQuery{UID: uid, OrgId: c.OrgID} @@ -92,7 +92,7 @@ func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetPlaylistItems(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPlaylistItems(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] cmd := playlist.GetPlaylistByUidQuery{UID: uid, OrgId: c.OrgID} @@ -114,7 +114,7 @@ func (hs *HTTPServer) GetPlaylistItems(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPlaylistDashboards(c *contextmodel.ReqContext) response.Response { playlistUID := web.Params(c.Req)[":uid"] playlists, err := hs.LoadPlaylistDashboards(c.Req.Context(), c.OrgID, c.SignedInUser, playlistUID) @@ -135,7 +135,7 @@ func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Respo // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeletePlaylist(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] cmd := playlist.DeletePlaylistCommand{UID: uid, OrgId: c.OrgID} @@ -156,7 +156,7 @@ func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreatePlaylist(c *contextmodel.ReqContext) response.Response { cmd := playlist.CreatePlaylistCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -181,7 +181,7 @@ func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdatePlaylist(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdatePlaylist(c *contextmodel.ReqContext) response.Response { cmd := playlist.UpdatePlaylistCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index 62274f85aec..d024d2c4168 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -19,11 +19,12 @@ func (hs *HTTPServer) populateDashboardsByID(ctx context.Context, dashboardByIDs if len(dashboardByIDs) > 0 { dashboardQuery := dashboards.GetDashboardsQuery{DashboardIDs: dashboardByIDs} - if err := hs.DashboardService.GetDashboards(ctx, &dashboardQuery); err != nil { + dashboardQueryResult, err := hs.DashboardService.GetDashboards(ctx, &dashboardQuery) + if err != nil { return result, err } - for _, item := range dashboardQuery.Result { + for _, item := range dashboardQueryResult { result = append(result, dtos.PlaylistDashboard{ Id: item.ID, Slug: item.Slug, diff --git a/pkg/api/plugin_dashboards.go b/pkg/api/plugin_dashboards.go index 7710c41ca1e..4446b490825 100644 --- a/pkg/api/plugin_dashboards.go +++ b/pkg/api/plugin_dashboards.go @@ -5,8 +5,8 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/web" ) @@ -14,7 +14,7 @@ import ( // GetPluginDashboards get plugin dashboards. // // /api/plugins/:pluginId/dashboards -func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPluginDashboards(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] listReq := &plugindashboards.ListPluginDashboardsRequest{ diff --git a/pkg/api/plugin_proxy.go b/pkg/api/plugin_proxy.go index 509c1f784bf..0ff09d45bb0 100644 --- a/pkg/api/plugin_proxy.go +++ b/pkg/api/plugin_proxy.go @@ -9,12 +9,12 @@ import ( "time" "github.com/grafana/grafana/pkg/api/pluginproxy" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) ProxyPluginRequest(c *models.ReqContext) { +func (hs *HTTPServer) ProxyPluginRequest(c *contextmodel.ReqContext) { var once sync.Once var pluginProxyTransport *http.Transport once.Do(func() { @@ -63,6 +63,6 @@ func extractProxyPath(originalRawPath string) string { return pluginProxyPathRegexp.ReplaceAllString(originalRawPath, "") } -func getProxyPath(c *models.ReqContext) string { +func getProxyPath(c *contextmodel.ReqContext) string { return extractProxyPath(c.Req.URL.EscapedPath()) } diff --git a/pkg/api/plugin_resource.go b/pkg/api/plugin_resource.go index 970c4d6a4bc..a9b0ab61af0 100644 --- a/pkg/api/plugin_resource.go +++ b/pkg/api/plugin_resource.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/backendplugin" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/web" @@ -22,11 +22,11 @@ import ( // CallResource passes a resource call from a plugin to the backend plugin. // // /api/plugins/:pluginId/resources/* -func (hs *HTTPServer) CallResource(c *models.ReqContext) { +func (hs *HTTPServer) CallResource(c *contextmodel.ReqContext) { hs.callPluginResource(c, web.Params(c.Req)[":pluginId"]) } -func (hs *HTTPServer) callPluginResource(c *models.ReqContext, pluginID string) { +func (hs *HTTPServer) callPluginResource(c *contextmodel.ReqContext, pluginID string) { pCtx, found, err := hs.PluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser) if err != nil { c.JsonApiErr(500, "Failed to get plugin settings", err) @@ -48,7 +48,7 @@ func (hs *HTTPServer) callPluginResource(c *models.ReqContext, pluginID string) } } -func (hs *HTTPServer) callPluginResourceWithDataSource(c *models.ReqContext, pluginID string, ds *datasources.DataSource) { +func (hs *HTTPServer) callPluginResourceWithDataSource(c *contextmodel.ReqContext, pluginID string, ds *datasources.DataSource) { pCtx, found, err := hs.PluginContextProvider.GetWithDataSource(c.Req.Context(), pluginID, c.SignedInUser, ds) if err != nil { c.JsonApiErr(500, "Failed to get plugin settings", err) @@ -81,7 +81,7 @@ func (hs *HTTPServer) callPluginResourceWithDataSource(c *models.ReqContext, plu } } -func (hs *HTTPServer) pluginResourceRequest(c *models.ReqContext) (*http.Request, error) { +func (hs *HTTPServer) pluginResourceRequest(c *contextmodel.ReqContext) (*http.Request, error) { clonedReq := c.Req.Clone(c.Req.Context()) rawURL := web.Params(c.Req)["*"] if clonedReq.URL.RawQuery != "" { @@ -207,7 +207,7 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt } } -func handleCallResourceError(err error, reqCtx *models.ReqContext) { +func handleCallResourceError(err error, reqCtx *contextmodel.ReqContext) { if errors.Is(err, backendplugin.ErrPluginUnavailable) { reqCtx.JsonApiErr(503, "Plugin unavailable", err) return diff --git a/pkg/api/plugin_resource_test.go b/pkg/api/plugin_resource_test.go new file mode 100644 index 00000000000..a63e9485264 --- /dev/null +++ b/pkg/api/plugin_resource_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" + "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" + "github.com/grafana/grafana/pkg/plugins/config" + pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client" + "github.com/grafana/grafana/pkg/plugins/manager/fakes" + "github.com/grafana/grafana/pkg/plugins/manager/loader" + "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" + "github.com/grafana/grafana/pkg/plugins/manager/registry" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/store" + "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/grafana/grafana/pkg/services/accesscontrol" + datasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" + pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch" + "github.com/grafana/grafana/pkg/tsdb/testdatasource" + "github.com/grafana/grafana/pkg/web/webtest" +) + +func TestCallResource(t *testing.T) { + staticRootPath, err := filepath.Abs("../../public/") + require.NoError(t, err) + + cfg := setting.NewCfg() + cfg.StaticRootPath = staticRootPath + cfg.IsFeatureToggleEnabled = func(_ string) bool { + return false + } + cfg.Azure = &azsettings.AzureSettings{} + + coreRegistry := coreplugin.ProvideCoreRegistry(nil, &cloudwatch.CloudWatchService{}, nil, nil, nil, nil, + nil, nil, nil, nil, testdatasource.ProvideService(cfg, featuremgmt.WithFeatures()), nil, nil, nil, nil, nil, nil) + pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg) + reg := registry.ProvideService() + cdn := pluginscdn.ProvideService(pCfg) + l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg), + reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn)) + ps, err := store.ProvideService(cfg, pCfg, reg, l) + require.NoError(t, err) + + pcp := plugincontext.ProvideService(localcache.ProvideService(), ps, &datasources.FakeCacheService{}, &datasources.FakeDataSourceService{}, pluginSettings.ProvideService(db.InitTestDB(t), nil)) + + srv := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = cfg + hs.PluginContextProvider = pcp + hs.QuotaService = quotatest.New(false, nil) + hs.pluginStore = ps + hs.pluginClient = pluginClient.ProvideService(reg, pCfg) + }) + + t.Run("Test successful response is received for valid request", func(t *testing.T) { + req := srv.NewPostRequest("/api/plugins/testdata/resources/test", strings.NewReader("{ \"test\": true }")) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: plugins.ActionAppAccess, Scope: plugins.ScopeProvider.GetResourceAllScope()}, + }), + }}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var body = make(map[string]interface{}) + err = json.Unmarshal(b, &body) + require.NoError(t, err) + + require.Equal(t, "Hello world from test datasource!", body["message"]) + require.NoError(t, resp.Body.Close()) + require.Equal(t, 200, resp.StatusCode) + }) + + pc, err := pluginClient.NewDecorator(&fakes.FakePluginClient{ + CallResourceHandlerFunc: backend.CallResourceHandlerFunc(func(ctx context.Context, + req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return errors.New("something went wrong") + }), + }, pluginsintegration.CreateMiddlewares(cfg, &oauthtokentest.Service{})...) + require.NoError(t, err) + + srv = SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = cfg + hs.PluginContextProvider = pcp + hs.QuotaService = quotatest.New(false, nil) + hs.pluginStore = ps + hs.pluginClient = pc + }) + + t.Run("Test error is properly propagated to API response", func(t *testing.T) { + req := srv.NewGetRequest("/api/plugins/testdata/resources/scenarios") + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: plugins.ActionAppAccess, Scope: plugins.ScopeProvider.GetResourceAllScope()}, + }), + }}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + body := new(strings.Builder) + _, err = io.Copy(body, resp.Body) + require.NoError(t, err) + + expectedBody := `{ "error": "something went wrong", "message": "Failed to call resource", "traceID": "" }` + require.JSONEq(t, expectedBody, body.String()) + require.NoError(t, resp.Body.Close()) + require.Equal(t, 500, resp.StatusCode) + }) +} diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index ba450c9f056..fa9cc95fa6b 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -17,8 +17,8 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/setting" @@ -33,7 +33,7 @@ var ( type DataSourceProxy struct { ds *datasources.DataSource - ctx *models.ReqContext + ctx *contextmodel.ReqContext targetUrl *url.URL proxyPath string matchedRoute *plugins.Route @@ -50,7 +50,7 @@ type httpClient interface { } // NewDataSourceProxy creates a new Datasource proxy -func NewDataSourceProxy(ds *datasources.DataSource, pluginRoutes []*plugins.Route, ctx *models.ReqContext, +func NewDataSourceProxy(ds *datasources.DataSource, pluginRoutes []*plugins.Route, ctx *contextmodel.ReqContext, proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider, oAuthTokenService oauthtoken.OAuthTokenService, dsService datasources.DataSourceService, tracer tracing.Tracer) (*DataSourceProxy, error) { @@ -343,7 +343,7 @@ func (proxy *DataSourceProxy) logRequest() { "body", body) } -func checkWhiteList(c *models.ReqContext, host string) bool { +func checkWhiteList(c *contextmodel.ReqContext, host string) bool { if host != "" && len(setting.DataProxyWhiteList) > 0 { if _, exists := setting.DataProxyWhiteList[host]; !exists { c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil) diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index a552bb0ba5f..bf9fa018d77 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -25,12 +25,13 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -128,10 +129,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - setUp := func() (*models.ReqContext, *http.Request) { + setUp := func() (*contextmodel.ReqContext, *http.Request) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor}, } @@ -286,7 +287,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor}, } @@ -372,7 +373,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When proxying graphite", func(t *testing.T) { var routes []*plugins.Route ds := &datasources.DataSource{Url: "htttp://graphite:8080", Type: datasources.DS_GRAPHITE} - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -401,7 +402,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { User: "user", } - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} var routes []*plugins.Route sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -429,7 +430,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { JsonData: json, } - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} var routes []*plugins.Route sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -461,7 +462,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { JsonData: json, } - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} var pluginRoutes []*plugins.Route sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -488,7 +489,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { Type: "custom-datasource", Url: "http://host/root/", } - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} var routes []*plugins.Route sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -522,7 +523,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{UserID: 1}, Context: &web.Context{Req: req}, } @@ -563,7 +564,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When SendUserHeader config is enabled", func(t *testing.T) { req := getDatasourceProxiedRequest( t, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -576,7 +577,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When SendUserHeader config is disabled", func(t *testing.T) { req := getDatasourceProxiedRequest( t, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -590,7 +591,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When SendUserHeader config is enabled but user is anonymous", func(t *testing.T) { req := getDatasourceProxiedRequest( t, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{IsAnonymous: true}, }, &setting.Cfg{SendUserHeader: true}, @@ -635,7 +636,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { writeCb func(w http.ResponseWriter, r *http.Request) } - setUp := func(t *testing.T, cfgs ...setUpCfg) (*models.ReqContext, *datasources.DataSource) { + setUp := func(t *testing.T, cfgs ...setUpCfg) (*contextmodel.ReqContext, *datasources.DataSource) { writeErr = nil backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -668,7 +669,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { } } - return &models.ReqContext{ + return &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{}, Context: &web.Context{ Req: httptest.NewRequest("GET", "/render", nil), @@ -822,7 +823,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { } func TestNewDataSourceProxy_InvalidURL(t *testing.T) { - ctx := models.ReqContext{ + ctx := contextmodel.ReqContext{ Context: &web.Context{}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor}, } @@ -846,7 +847,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { } func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { - ctx := models.ReqContext{ + ctx := contextmodel.ReqContext{ Context: &web.Context{}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor}, } @@ -871,7 +872,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { // Test wth MSSQL type data sources. func TestNewDataSourceProxy_MSSQL(t *testing.T) { - ctx := models.ReqContext{ + ctx := contextmodel.ReqContext{ Context: &web.Context{}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor}, } @@ -926,7 +927,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { } // getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. -func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *setting.Cfg) *http.Request { +func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg *setting.Cfg) *http.Request { ds := &datasources.DataSource{ Type: "custom", Url: "http://host/root/", @@ -1052,7 +1053,7 @@ func createAuthTest(t *testing.T, secretsStore secretskvs.SecretsKVStore, dsType } func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secretsStore secretskvs.SecretsKVStore, cfg *setting.Cfg, test *testCase) { - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} tracer := tracing.InitializeTracerForTest() var routes []*plugins.Route @@ -1089,10 +1090,10 @@ func Test_PathCheck(t *testing.T) { } tracer := tracing.InitializeTracerForTest() - setUp := func() (*models.ReqContext, *http.Request) { + setUp := func() (*contextmodel.ReqContext, *http.Request) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgRole: org.RoleViewer}, } @@ -1125,14 +1126,14 @@ func (m *mockOAuthTokenService) IsOAuthPassThruEnabled(ds *datasources.DataSourc return m.oAuthEnabled } -func (m *mockOAuthTokenService) HasOAuthEntry(context.Context, *user.SignedInUser) (*models.UserAuth, bool, error) { +func (m *mockOAuthTokenService) HasOAuthEntry(context.Context, *user.SignedInUser) (*login.UserAuth, bool, error) { return nil, false, nil } -func (m *mockOAuthTokenService) TryTokenRefresh(context.Context, *models.UserAuth) error { +func (m *mockOAuthTokenService) TryTokenRefresh(context.Context, *login.UserAuth) error { return nil } -func (m *mockOAuthTokenService) InvalidateOAuthTokens(context.Context, *models.UserAuth) error { +func (m *mockOAuthTokenService) InvalidateOAuthTokens(context.Context, *login.UserAuth) error { return nil } diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index e77b83d0cb3..a69abb209be 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -8,8 +8,8 @@ import ( "net/url" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -22,7 +22,7 @@ import ( type PluginProxy struct { ps *pluginsettings.DTO pluginRoutes []*plugins.Route - ctx *models.ReqContext + ctx *contextmodel.ReqContext proxyPath string matchedRoute *plugins.Route cfg *setting.Cfg @@ -32,7 +32,7 @@ type PluginProxy struct { } // NewPluginProxy creates a plugin proxy. -func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, ctx *models.ReqContext, +func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, ctx *contextmodel.ReqContext, proxyPath string, cfg *setting.Cfg, secretsService secrets.Service, tracer tracing.Tracer, transport *http.Transport) (*PluginProxy, error) { return &PluginProxy{ diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index d1e18072caa..4ac71cb9291 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/secrets" @@ -49,7 +49,7 @@ func TestPluginProxy(t *testing.T) { }, }, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -72,7 +72,7 @@ func TestPluginProxy(t *testing.T) { t, &pluginsettings.DTO{}, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -96,7 +96,7 @@ func TestPluginProxy(t *testing.T) { t, &pluginsettings.DTO{}, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -119,7 +119,7 @@ func TestPluginProxy(t *testing.T) { t, &pluginsettings.DTO{}, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{IsAnonymous: true}, Context: &web.Context{ Req: httpReq, @@ -150,7 +150,7 @@ func TestPluginProxy(t *testing.T) { }, }, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -178,7 +178,7 @@ func TestPluginProxy(t *testing.T) { t, &pluginsettings.DTO{}, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -216,7 +216,7 @@ func TestPluginProxy(t *testing.T) { SecureJSONData: encryptedJsonData, }, secretsService, - &models.ReqContext{ + &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, @@ -250,7 +250,7 @@ func TestPluginProxy(t *testing.T) { }, } - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{}, Context: &web.Context{ Req: httptest.NewRequest("GET", "/", nil), @@ -388,7 +388,7 @@ func TestPluginProxyRoutes(t *testing.T) { responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{}, Context: &web.Context{ Req: httptest.NewRequest("GET", tc.proxyPath, nil), @@ -420,7 +420,7 @@ func TestPluginProxyRoutes(t *testing.T) { } // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. -func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsService secrets.Service, ctx *models.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request { +func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsService secrets.Service, ctx *contextmodel.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request { // insert dummy route if none is specified if route == nil { route = &plugins.Route{ diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 757c39fd999..2658391d2cc 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -15,15 +15,17 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "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/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/plugins/storage" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" @@ -33,7 +35,15 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { +// pluginsCDNFallbackRedirectRequests is a metric counter keeping track of how many +// requests are received on the plugins CDN backend redirect fallback handler. +var pluginsCDNFallbackRedirectRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "grafana", + Name: "plugins_cdn_fallback_redirect_requests_total", + Help: "Number of requests to the plugins CDN backend redirect fallback handler.", +}, []string{"plugin_id", "plugin_version"}) + +func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Response { typeFilter := c.Query("type") enabledFilter := c.Query("enabled") embeddedFilter := c.Query("embedded") @@ -158,7 +168,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, result) } -func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) @@ -227,7 +237,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon return response.JSON(http.StatusOK, dto) } -func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdatePluginSetting(c *contextmodel.ReqContext) response.Response { cmd := pluginsettings.UpdatePluginSettingCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -256,7 +266,7 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons return response.Success("Plugin settings updated") } -func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] name := web.Params(c.Req)[":name"] @@ -286,7 +296,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response // CollectPluginMetrics collect metrics from a plugin. // // /api/plugins/:pluginId/metrics -func (hs *HTTPServer) CollectPluginMetrics(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CollectPluginMetrics(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] resp, err := hs.pluginClient.CollectMetrics(c.Req.Context(), &backend.CollectMetricsRequest{PluginContext: backend.PluginContext{PluginID: pluginID}}) if err != nil { @@ -301,8 +311,15 @@ func (hs *HTTPServer) CollectPluginMetrics(c *models.ReqContext) response.Respon // getPluginAssets returns public plugin assets (images, JS, etc.) // +// If the plugin has cdn = false in its config (default), it will always attempt to return the asset +// from the local filesystem. +// +// If the plugin has cdn = true and hs.Cfg.PluginsCDNURLTemplate is empty, it will get the file +// from the local filesystem. If hs.Cfg.PluginsCDNURLTemplate is not empty, +// this handler returns a redirect to the plugin asset file on the specified CDN. +// // /public/plugins/:pluginId/* -func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { +func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) { pluginID := web.Params(c.Req)[":pluginId"] plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) if !exists { @@ -318,7 +335,19 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { return } - f, err := plugin.File(requestedFile) + if hs.pluginsCDNService.PluginSupported(pluginID) { + // Send a redirect to the client + hs.redirectCDNPluginAsset(c, plugin, requestedFile) + return + } + + // Send the actual file to the client from local filesystem + hs.serveLocalPluginAsset(c, plugin, requestedFile) +} + +// serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client. +func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) { + f, err := plugin.File(assetPath) if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { c.JsonApiErr(404, "Plugin file not found", nil) @@ -346,20 +375,42 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { } if rs, ok := f.(io.ReadSeeker); ok { - http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), rs) - } else { - b, err := io.ReadAll(f) - if err != nil { - c.JsonApiErr(500, "Plugin file exists but could not read", err) - return - } - http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), bytes.NewReader(b)) + http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), rs) + return } + + b, err := io.ReadAll(f) + if err != nil { + c.JsonApiErr(500, "Plugin file exists but could not read", err) + return + } + http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), bytes.NewReader(b)) +} + +// redirectCDNPluginAsset redirects the http request to specified asset path on the configured plugins CDN. +func (hs *HTTPServer) redirectCDNPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) { + remoteURL, err := hs.pluginsCDNService.AssetURL(plugin.ID, plugin.Info.Version, assetPath) + if err != nil { + c.JsonApiErr(500, "Failed to get CDN plugin asset remote URL", err) + return + } + hs.log.Warn( + "plugin cdn redirect hit", + "pluginID", plugin.ID, + "pluginVersion", plugin.Info.Version, + "assetPath", assetPath, + "remoteURL", remoteURL, + ) + pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{ + "plugin_id": plugin.ID, + "plugin_version": plugin.Info.Version, + }).Inc() + http.Redirect(c.Resp, c.Req, remoteURL, http.StatusTemporaryRedirect) } // CheckHealth returns the health of a plugin. // /api/plugins/:pluginId/health -func (hs *HTTPServer) CheckHealth(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CheckHealth(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] pCtx, found, err := hs.PluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser) @@ -401,11 +452,11 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, payload) } -func (hs *HTTPServer) GetPluginErrorsList(_ *models.ReqContext) response.Response { +func (hs *HTTPServer) GetPluginErrorsList(_ *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, hs.pluginErrorResolver.PluginErrors()) } -func (hs *HTTPServer) InstallPlugin(c *models.ReqContext) response.Response { +func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Response { dto := dtos.InstallPluginCommand{} if err := web.Bind(c.Req, &dto); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -444,7 +495,7 @@ func (hs *HTTPServer) InstallPlugin(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, []byte{}) } -func (hs *HTTPServer) UninstallPlugin(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID) diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 7f2c783a518..fef5ceba31b 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -12,7 +12,9 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -20,9 +22,11 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/pluginsettings" @@ -139,7 +143,7 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) { req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(1, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) - assert.Equal(t, tc.expectedCode, res.StatusCode) + require.Equal(t, tc.expectedCode, res.StatusCode) require.NoError(t, res.Body.Close()) }) @@ -148,12 +152,105 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) { req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(1, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) - assert.Equal(t, tc.expectedCode, res.StatusCode) + require.Equal(t, tc.expectedCode, res.StatusCode) require.NoError(t, res.Body.Close()) }) } } +func Test_GetPluginAssetCDNRedirect(t *testing.T) { + const cdnPluginID = "cdn-plugin" + const nonCDNPluginID = "non-cdn-plugin" + t.Run("Plugin CDN asset redirect", func(t *testing.T) { + cdnPlugin := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: cdnPluginID, Info: plugins.Info{Version: "1.0.0"}}, + } + nonCdnPlugin := &plugins.Plugin{ + JSONData: plugins.JSONData{ID: nonCDNPluginID, Info: plugins.Info{Version: "2.0.0"}}, + } + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + cdnPlugin.ToDTO(), + nonCdnPlugin.ToDTO(), + }, + } + cfg := setting.NewCfg() + cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}" + cfg.PluginSettings = map[string]map[string]string{ + cdnPluginID: {"cdn": "true"}, + } + + const cdnFolderBaseURL = "https://cdn.example.com/cdn-plugin/1.0.0/public/plugins/cdn-plugin" + + type tc struct { + assetURL string + expRelativeURL string + } + for _, cas := range []tc{ + {"module.js", "module.js"}, + {"other/folder/file.js", "other/folder/file.js"}, + {"double////slashes/file.js", "double/slashes/file.js"}, + } { + pluginAssetScenario( + t, + "When calling GET for a CDN plugin on", + fmt.Sprintf("/public/plugins/%s/%s", cdnPluginID, cas.assetURL), + "/public/plugins/:pluginId/*", + cfg, service, func(sc *scenarioContext) { + // Get the prometheus metric (to test that the handler is instrumented correctly) + counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{ + "plugin_id": cdnPluginID, + "plugin_version": "1.0.0", + }) + + // Encode the prometheus metric and get its value + var m dto.Metric + require.NoError(t, counter.Write(&m)) + before := m.Counter.GetValue() + + // Call handler + callGetPluginAsset(sc) + + // Check redirect code + location + require.Equal(t, http.StatusTemporaryRedirect, sc.resp.Code, "wrong status code") + require.Equal(t, cdnFolderBaseURL+"/"+cas.expRelativeURL, sc.resp.Header().Get("Location"), "wrong location header") + + // Check metric + require.NoError(t, counter.Write(&m)) + require.Equal(t, before+1, m.Counter.GetValue(), "prometheus metric not incremented") + }, + ) + } + pluginAssetScenario( + t, + "When calling GET for a non-CDN plugin on", + fmt.Sprintf("/public/plugins/%s/%s", nonCDNPluginID, "module.js"), + "/public/plugins/:pluginId/*", + cfg, service, func(sc *scenarioContext) { + // Here the metric should not increment + var m dto.Metric + counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{ + "plugin_id": nonCDNPluginID, + "plugin_version": "2.0.0", + }) + require.NoError(t, counter.Write(&m)) + require.Zero(t, m.Counter.GetValue()) + + // Call handler + callGetPluginAsset(sc) + + // 404 implies access to fs + require.Equal(t, http.StatusNotFound, sc.resp.Code) + require.Empty(t, sc.resp.Header().Get("Location")) + + // Ensure the metric did not change + require.NoError(t, counter.Write(&m)) + require.Zero(t, m.Counter.GetValue()) + }, + ) + }) +} + func Test_GetPluginAssets(t *testing.T) { pluginID := "test-plugin" pluginDir := "." @@ -185,8 +282,8 @@ func Test_GetPluginAssets(t *testing.T) { } url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) - pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, - func(sc *scenarioContext) { + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", + setting.NewCfg(), service, func(sc *scenarioContext) { callGetPluginAsset(sc) require.Equal(t, 200, sc.resp.Code) @@ -201,8 +298,8 @@ func Test_GetPluginAssets(t *testing.T) { } url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name()) - pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, - func(sc *scenarioContext) { + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", + setting.NewCfg(), service, func(sc *scenarioContext) { callGetPluginAsset(sc) require.Equal(t, 404, sc.resp.Code) @@ -217,8 +314,8 @@ func Test_GetPluginAssets(t *testing.T) { requestedFile := "nonExistent" url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) - pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, - func(sc *scenarioContext) { + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", + setting.NewCfg(), service, func(sc *scenarioContext) { callGetPluginAsset(sc) var respJson map[string]interface{} @@ -237,8 +334,8 @@ func Test_GetPluginAssets(t *testing.T) { requestedFile := "nonExistent" url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) - pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, - func(sc *scenarioContext) { + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", + setting.NewCfg(), service, func(sc *scenarioContext) { callGetPluginAsset(sc) var respJson map[string]interface{} @@ -262,8 +359,8 @@ func Test_GetPluginAssets(t *testing.T) { l := &logtest.Fake{} url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) - pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, - func(sc *scenarioContext) { + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", + setting.NewCfg(), service, func(sc *scenarioContext) { callGetPluginAsset(sc) require.Equal(t, 200, sc.resp.Code) @@ -383,16 +480,22 @@ func callGetPluginAsset(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } -func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store, - fn scenarioFunc) { +func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, + cfg *setting.Cfg, pluginStore plugins.Store, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { + cfg.IsFeatureToggleEnabled = func(_ string) bool { return false } hs := HTTPServer{ - Cfg: setting.NewCfg(), + Cfg: cfg, pluginStore: pluginStore, + log: log.NewNopLogger(), + pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{ + PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, + PluginSettings: cfg.PluginSettings, + }), } sc := setupScenarioContext(t, url) - sc.defaultHandler = func(c *models.ReqContext) { + sc.defaultHandler = func(c *contextmodel.ReqContext) { sc.context = c hs.getPluginAssets(c) } diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index a005b9cdbc9..f008270c444 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -6,7 +6,8 @@ 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/kinds/preferences" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/web" @@ -19,7 +20,7 @@ const ( ) // POST /api/preferences/set-home-dash -func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SetHomeDashboard(c *contextmodel.ReqContext) response.Response { cmd := pref.SavePreferenceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -35,11 +36,11 @@ func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response { if query.UID == "" { dashboardID = 0 // clear the value } else { - err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) + queryResult, err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err != nil { return response.Error(404, "Dashboard not found", err) } - dashboardID = query.Result.ID + dashboardID = queryResult.ID } } @@ -60,7 +61,7 @@ func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response { // 200: getPreferencesResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetUserPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserPreferences(c *contextmodel.ReqContext) response.Response { return hs.getPreferencesFor(c.Req.Context(), c.OrgID, c.UserID, 0) } @@ -77,29 +78,37 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team // when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response if preference.HomeDashboardID != 0 { query := dashboards.GetDashboardQuery{ID: preference.HomeDashboardID, OrgID: orgID} - err = hs.DashboardService.GetDashboard(ctx, &query) + queryResult, err := hs.DashboardService.GetDashboard(ctx, &query) if err == nil { - dashboardUID = query.Result.UID + dashboardUID = queryResult.UID } } - weekStart := "" - if preference.WeekStart != nil { - weekStart = *preference.WeekStart - } + dto := preferences.Preferences{} - dto := dtos.Prefs{ - Theme: preference.Theme, - HomeDashboardID: preference.HomeDashboardID, - HomeDashboardUID: dashboardUID, - Timezone: preference.Timezone, - WeekStart: weekStart, + if preference.WeekStart != nil && *preference.WeekStart != "" { + dto.WeekStart = preference.WeekStart + } + if preference.Theme != "" { + dto.Theme = &preference.Theme + } + if dashboardUID != "" { + dto.HomeDashboardUID = &dashboardUID + } + if preference.Timezone != "" { + dto.Timezone = &preference.Timezone } if preference.JSONData != nil { - dto.Language = preference.JSONData.Language - dto.Navbar = preference.JSONData.Navbar - dto.QueryHistory = preference.JSONData.QueryHistory + if preference.JSONData.Language != "" { + dto.Language = &preference.JSONData.Language + } + + if preference.JSONData.QueryHistory.HomeTab != "" { + dto.QueryHistory = &preferences.QueryHistoryPreference{ + HomeTab: &preference.JSONData.QueryHistory.HomeTab, + } + } } return response.JSON(http.StatusOK, &dto) @@ -116,7 +125,7 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) UpdateUserPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateUserPreferences(c *contextmodel.ReqContext) response.Response { dtoCmd := dtos.UpdatePrefsCmd{} if err := web.Bind(c.Req, &dtoCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -136,11 +145,11 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t // clear the value dashboardID = 0 } else { - err := hs.DashboardService.GetDashboard(ctx, &query) + queryResult, err := hs.DashboardService.GetDashboard(ctx, &query) if err != nil { return response.Error(404, "Dashboard not found", err) } - dashboardID = query.Result.ID + dashboardID = queryResult.ID } } dtoCmd.HomeDashboardID = dashboardID @@ -155,7 +164,6 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t WeekStart: dtoCmd.WeekStart, HomeDashboardID: dtoCmd.HomeDashboardID, QueryHistory: dtoCmd.QueryHistory, - Navbar: dtoCmd.Navbar, } if err := hs.preferenceService.Save(ctx, &saveCmd); err != nil { @@ -174,7 +182,7 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) PatchUserPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PatchUserPreferences(c *contextmodel.ReqContext) response.Response { dtoCmd := dtos.PatchPrefsCmd{} if err := web.Bind(c.Req, &dtoCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -196,11 +204,11 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te defaultDash := int64(0) dashboardID = &defaultDash } else { - err := hs.DashboardService.GetDashboard(ctx, &query) + queryResult, err := hs.DashboardService.GetDashboard(ctx, &query) if err != nil { return response.Error(404, "Dashboard not found", err) } - dashboardID = &query.Result.ID + dashboardID = &queryResult.ID } } dtoCmd.HomeDashboardID = dashboardID @@ -214,7 +222,6 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te WeekStart: dtoCmd.WeekStart, HomeDashboardID: dtoCmd.HomeDashboardID, Language: dtoCmd.Language, - Navbar: dtoCmd.Navbar, QueryHistory: dtoCmd.QueryHistory, } @@ -234,7 +241,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetOrgPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgPreferences(c *contextmodel.ReqContext) response.Response { return hs.getPreferencesFor(c.Req.Context(), c.OrgID, 0, 0) } @@ -248,7 +255,7 @@ func (hs *HTTPServer) GetOrgPreferences(c *models.ReqContext) response.Response // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateOrgPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrgPreferences(c *contextmodel.ReqContext) response.Response { dtoCmd := dtos.UpdatePrefsCmd{} if err := web.Bind(c.Req, &dtoCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -267,7 +274,7 @@ func (hs *HTTPServer) UpdateOrgPreferences(c *models.ReqContext) response.Respon // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) PatchOrgPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) PatchOrgPreferences(c *contextmodel.ReqContext) response.Response { dtoCmd := dtos.PatchPrefsCmd{} if err := web.Bind(c.Req, &dtoCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -292,7 +299,7 @@ type UpdateOrgPreferencesParams struct { // swagger:response getPreferencesResponse type GetPreferencesResponse struct { // in:body - Body dtos.Prefs `json:"body"` + Body preferences.Preferences `json:"body"` } // swagger:parameters patchUserPreferences diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index 6b9abd2090e..871e0f63bad 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -39,10 +39,8 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_LegacyAccessControl(t *testing.T) cfg := setting.NewCfg() cfg.RBACEnabled = false dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{UID: "home", ID: 1} - }).Return(nil) + qResult := &dashboards.Dashboard{UID: "home", ID: 1} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) prefService := preftest.NewPreferenceServiceFake() prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} @@ -80,10 +78,8 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_AccessControl(t *testing.T) { prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{UID: "home", ID: 1} - }).Return(nil) + qResult := &dashboards.Dashboard{UID: "home", ID: 1} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() @@ -196,10 +192,8 @@ func TestAPIEndpoint_PatchUserPreferences(t *testing.T) { cfg.RBACEnabled = false dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{UID: "home", ID: 1} - }).Return(nil) + qResult := &dashboards.Dashboard{UID: "home", ID: 1} + dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = cfg diff --git a/pkg/api/quota.go b/pkg/api/quota.go index dd0ee9f538d..d712e55824a 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) @@ -22,7 +22,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetCurrentOrgQuotas(c *contextmodel.ReqContext) response.Response { return hs.getOrgQuotasHelper(c, c.OrgID) } @@ -38,7 +38,7 @@ func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Respons // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetOrgQuotas(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetOrgQuotas(c *contextmodel.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) @@ -46,7 +46,7 @@ func (hs *HTTPServer) GetOrgQuotas(c *models.ReqContext) response.Response { return hs.getOrgQuotasHelper(c, orgId) } -func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) response.Response { +func (hs *HTTPServer) getOrgQuotasHelper(c *contextmodel.ReqContext, orgID int64) response.Response { q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.OrgScope, orgID) if err != nil { return response.ErrOrFallback(http.StatusInternalServerError, "failed to get quota", err) @@ -69,7 +69,7 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateOrgQuota(c *contextmodel.ReqContext) response.Response { cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { @@ -113,7 +113,7 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserQuotas(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) @@ -142,7 +142,7 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateUserQuota(c *contextmodel.ReqContext) response.Response { cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { diff --git a/pkg/api/render.go b/pkg/api/render.go index 6b2c3480998..de80fbf8db5 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -8,12 +8,13 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) RenderToPng(c *models.ReqContext) { +func (hs *HTTPServer) RenderToPng(c *contextmodel.ReqContext) { queryReader, err := util.NewURLQueryReader(c.Req.URL) if err != nil { c.Handle(hs.Cfg, 400, "Render parameters error", err) diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go index 744a8f0ab45..aa8839d169f 100644 --- a/pkg/api/response/response.go +++ b/pkg/api/response/response.go @@ -9,9 +9,10 @@ import ( "reflect" jsoniter "github.com/json-iterator/go" + "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -19,7 +20,7 @@ import ( // Response is an HTTP response interface. type Response interface { // WriteTo writes to a context. - WriteTo(ctx *models.ReqContext) + WriteTo(ctx *contextmodel.ReqContext) // Body gets the response's body. Body() []byte // Status gets the response's status. @@ -77,7 +78,7 @@ func (r *NormalResponse) ErrMessage() string { return r.errMessage } -func (r *NormalResponse) WriteTo(ctx *models.ReqContext) { +func (r *NormalResponse) WriteTo(ctx *contextmodel.ReqContext) { if r.err != nil { v := map[string]interface{}{} traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false) @@ -132,7 +133,7 @@ func (r StreamingResponse) Body() []byte { // WriteTo writes the response to the provided context. // Required to implement api.Response. -func (r StreamingResponse) WriteTo(ctx *models.ReqContext) { +func (r StreamingResponse) WriteTo(ctx *contextmodel.ReqContext) { header := ctx.Resp.Header() for k, v := range r.header { header[k] = v @@ -155,7 +156,7 @@ type RedirectResponse struct { } // WriteTo writes to a response. -func (r *RedirectResponse) WriteTo(ctx *models.ReqContext) { +func (r *RedirectResponse) WriteTo(ctx *contextmodel.ReqContext) { ctx.Redirect(r.location) } @@ -173,7 +174,8 @@ func (r *RedirectResponse) Body() []byte { // JSON creates a JSON response. func JSON(status int, body interface{}) *NormalResponse { - return Respond(status, body).SetHeader("Content-Type", "application/json") + return Respond(status, body). + SetHeader("Content-Type", "application/json") } // JSONStreaming creates a streaming JSON response. @@ -187,6 +189,30 @@ func JSONStreaming(status int, body interface{}) StreamingResponse { } } +// JSONDownload creates a JSON response indicating that it should be downloaded. +func JSONDownload(status int, body interface{}, filename string) *NormalResponse { + return JSON(status, body). + SetHeader("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, filename)) +} + +// YAML creates a YAML response. +func YAML(status int, body interface{}) *NormalResponse { + b, err := yaml.Marshal(body) + if err != nil { + return Error(http.StatusInternalServerError, "body yaml marshal", err) + } + // As of now, application/yaml is downloaded by default in chrome regardless of Content-Disposition, so we use text/yaml instead. + return Respond(status, b). + SetHeader("Content-Type", "text/yaml") +} + +// YAMLDownload creates a YAML response indicating that it should be downloaded. +func YAMLDownload(status int, body interface{}, filename string) *NormalResponse { + return YAML(status, body). + SetHeader("Content-Type", "application/yaml"). + SetHeader("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, filename)) +} + // Success create a successful response func Success(message string) *NormalResponse { resp := make(map[string]interface{}) diff --git a/pkg/api/response/web_hack.go b/pkg/api/response/web_hack.go index 81f3bfa4ac5..52aa9ee38a7 100644 --- a/pkg/api/response/web_hack.go +++ b/pkg/api/response/web_hack.go @@ -7,17 +7,17 @@ import ( "fmt" "net/http" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) type ( handlerStd = func(http.ResponseWriter, *http.Request) handlerStdCtx = func(http.ResponseWriter, *http.Request, *web.Context) - handlerStdReqCtx = func(http.ResponseWriter, *http.Request, *models.ReqContext) - handlerReqCtx = func(*models.ReqContext) - handlerReqCtxRes = func(*models.ReqContext) Response + handlerStdReqCtx = func(http.ResponseWriter, *http.Request, *contextmodel.ReqContext) + handlerReqCtx = func(*contextmodel.ReqContext) + handlerReqCtxRes = func(*contextmodel.ReqContext) Response handlerCtx = func(*web.Context) ) @@ -67,11 +67,11 @@ func webCtx(w http.ResponseWriter, r *http.Request) *web.Context { return ctx } -func reqCtx(w http.ResponseWriter, r *http.Request) *models.ReqContext { +func reqCtx(w http.ResponseWriter, r *http.Request) *contextmodel.ReqContext { wCtx := webCtx(w, r) - reqCtx, ok := wCtx.Req.Context().Value(ctxkey.Key{}).(*models.ReqContext) + reqCtx, ok := wCtx.Req.Context().Value(ctxkey.Key{}).(*contextmodel.ReqContext) if !ok { - panic("no *models.ReqContext found") + panic("no *contextmodel.ReqContext found") } return reqCtx } diff --git a/pkg/api/routing/routing.go b/pkg/api/routing/routing.go index 204ede8bf56..c5f5ec09759 100644 --- a/pkg/api/routing/routing.go +++ b/pkg/api/routing/routing.go @@ -2,7 +2,7 @@ package routing import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) @@ -12,8 +12,8 @@ var ( } ) -func Wrap(handler func(c *models.ReqContext) response.Response) web.Handler { - return func(c *models.ReqContext) { +func Wrap(handler func(c *contextmodel.ReqContext) response.Response) web.Handler { + return func(c *contextmodel.ReqContext) { if res := handler(c); res != nil { res.WriteTo(c) } diff --git a/pkg/api/search.go b/pkg/api/search.go index 290d11d3f05..91b8e2c06ac 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/util" @@ -20,7 +21,7 @@ import ( // 401: unauthorisedError // 422: unprocessableEntityError // 500: internalServerError -func (hs *HTTPServer) Search(c *models.ReqContext) response.Response { +func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { query := c.Query("query") tags := c.QueryStrings("tag") starred := c.Query("starred") @@ -28,14 +29,14 @@ func (hs *HTTPServer) Search(c *models.ReqContext) response.Response { page := c.QueryInt64("page") dashboardType := c.Query("type") sort := c.Query("sort") - permission := models.PERMISSION_VIEW + permission := dashboards.PERMISSION_VIEW if limit > 5000 { return response.Error(422, "Limit is above maximum allowed (5000), use page parameter to access hits beyond limit", nil) } if c.Query("permission") == "Edit" { - permission = models.PERMISSION_EDIT + permission = dashboards.PERMISSION_EDIT } dbIDs := make([]int64, 0) @@ -94,7 +95,7 @@ func (hs *HTTPServer) Search(c *models.ReqContext) response.Response { return hs.searchHitsWithMetadata(c, searchQuery.Result) } -func (hs *HTTPServer) searchHitsWithMetadata(c *models.ReqContext, hits models.HitList) response.Response { +func (hs *HTTPServer) searchHitsWithMetadata(c *contextmodel.ReqContext, hits models.HitList) response.Response { folderUIDs := make(map[string]bool) dashboardUIDs := make(map[string]bool) @@ -136,7 +137,7 @@ func (hs *HTTPServer) searchHitsWithMetadata(c *models.ReqContext, hits models.H // Responses: // 200: listSortOptionsResponse // 401: unauthorisedError -func (hs *HTTPServer) ListSortOptions(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ListSortOptions(c *contextmodel.ReqContext) response.Response { opts := hs.SearchService.SortOptions() res := []util.DynMap{} diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 040818547ae..f3ff7a6a8db 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -15,7 +15,7 @@ import ( ) // createShortURL handles requests to create short URLs. -func (hs *HTTPServer) createShortURL(c *models.ReqContext) response.Response { +func (hs *HTTPServer) createShortURL(c *contextmodel.ReqContext) response.Response { cmd := dtos.CreateShortURLCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Err(shorturls.ErrShortURLBadRequest.Errorf("bad request data: %w", err)) @@ -37,7 +37,7 @@ func (hs *HTTPServer) createShortURL(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, dto) } -func (hs *HTTPServer) redirectFromShortURL(c *models.ReqContext) { +func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) { shortURLUID := web.Params(c.Req)[":uid"] if !util.IsValidShortUID(shortURLUID) { diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go index 4a25feec1a6..17a4597958f 100644 --- a/pkg/api/short_url_test.go +++ b/pkg/api/short_url_test.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -61,7 +61,7 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern } sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 221749cb690..b14f74b4920 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -19,7 +19,7 @@ import ( ) // GET /api/user/signup/options -func GetSignUpOptions(c *models.ReqContext) response.Response { +func GetSignUpOptions(c *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, util.DynMap{ "verifyEmailEnabled": setting.VerifyEmailEnabled, "autoAssignOrg": setting.AutoAssignOrg, @@ -27,7 +27,7 @@ func GetSignUpOptions(c *models.ReqContext) response.Response { } // POST /api/user/signup -func (hs *HTTPServer) SignUp(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SignUp(c *contextmodel.ReqContext) response.Response { form := dtos.SignUpForm{} var err error if err = web.Bind(c.Req, &form); err != nil { @@ -75,7 +75,7 @@ func (hs *HTTPServer) SignUp(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, util.DynMap{"status": "SignUpCreated"}) } -func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SignUpStep2(c *contextmodel.ReqContext) response.Response { form := dtos.SignUpStep2Form{} if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/swagger.go b/pkg/api/swagger.go index 7c96b576b9f..667871fbe1b 100644 --- a/pkg/api/swagger.go +++ b/pkg/api/swagger.go @@ -3,9 +3,9 @@ package api import ( "net/http" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) -func swaggerUI(c *models.ReqContext) { +func swaggerUI(c *contextmodel.ReqContext) { c.HTML(http.StatusOK, "swagger", nil) } diff --git a/pkg/api/team.go b/pkg/api/team.go index 9bed235a498..5d5d2b6ed22 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -7,7 +7,8 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/util" @@ -24,7 +25,7 @@ import ( // 403: forbiddenError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { +func (hs *HTTPServer) CreateTeam(c *contextmodel.ReqContext) response.Response { cmd := team.CreateTeamCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -53,7 +54,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { // the SignedInUser is an empty struct therefore // an additional check whether it is an actual user is required if c.SignedInUser.IsRealUser() { - if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, c.SignedInUser.UserID, c.OrgID, t.ID, models.PERMISSION_ADMIN.String()); err != nil { + if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, c.SignedInUser.UserID, c.OrgID, t.ID, dashboards.PERMISSION_ADMIN.String()); err != nil { c.Logger.Error("Could not add creator to team", "error", err) } } else { @@ -77,7 +78,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { // 404: notFoundError // 409: conflictError // 500: internalServerError -func (hs *HTTPServer) UpdateTeam(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateTeam(c *contextmodel.ReqContext) response.Response { cmd := team.UpdateTeamCommand{} var err error if err := web.Bind(c.Req, &cmd); err != nil { @@ -115,7 +116,7 @@ func (hs *HTTPServer) UpdateTeam(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteTeamByID(c *contextmodel.ReqContext) response.Response { orgID := c.OrgID teamID, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { @@ -147,7 +148,7 @@ func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -201,7 +202,7 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { // UserFilter returns the user ID used in a filter when querying a team // 1. If the user is a viewer or editor, this will return the user's ID. // 2. If the user is an admin, this will return models.FilterIgnoreUser (0) -func userFilter(c *models.ReqContext) int64 { +func userFilter(c *contextmodel.ReqContext) int64 { userIdFilter := c.SignedInUser.UserID if c.OrgRole == org.RoleAdmin { userIdFilter = team.FilterIgnoreUser @@ -219,7 +220,7 @@ func userFilter(c *models.ReqContext) int64 { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetTeamByID(c *contextmodel.ReqContext) response.Response { teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "teamId is invalid", err) @@ -263,7 +264,7 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { // 200: getPreferencesResponse // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) GetTeamPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetTeamPreferences(c *contextmodel.ReqContext) response.Response { teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "teamId is invalid", err) @@ -289,7 +290,7 @@ func (hs *HTTPServer) GetTeamPreferences(c *models.ReqContext) response.Response // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (hs *HTTPServer) UpdateTeamPreferences(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateTeamPreferences(c *contextmodel.ReqContext) response.Response { dtoCmd := dtos.UpdatePrefsCmd{} if err := web.Bind(c.Req, &dtoCmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 17748226a90..3da7f5eda3b 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -9,8 +9,9 @@ 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/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/util" @@ -27,7 +28,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetTeamMembers(c *contextmodel.ReqContext) response.Response { teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "teamId is invalid", err) @@ -78,7 +79,7 @@ func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { +func (hs *HTTPServer) AddTeamMember(c *contextmodel.ReqContext) response.Response { cmd := team.AddTeamMemberCommand{} var err error if err := web.Bind(c.Req, &cmd); err != nil { @@ -124,7 +125,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateTeamMember(c *contextmodel.ReqContext) response.Response { cmd := team.UpdateTeamMemberCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -160,7 +161,7 @@ func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response { return response.Success("Team member updated") } -func getPermissionName(permission models.PermissionType) string { +func getPermissionName(permission dashboards.PermissionType) string { permissionName := permission.String() // Team member permission is 0, which maps to an empty string. // However, we want the team permission service to display "Member" for team members. This is a hack to make it work. @@ -180,7 +181,7 @@ func getPermissionName(permission models.PermissionType) string { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RemoveTeamMember(c *contextmodel.ReqContext) response.Response { orgId := c.OrgID teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index fd42bdef9c9..5d7643c3f56 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -8,21 +8,21 @@ import ( "strings" "testing" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/team" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/teamguardian/database" @@ -134,8 +134,8 @@ func TestAddTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { hs.teamService = teamtest.NewFakeService() store := &database.TeamGuardianStoreMock{} store.On("GetTeamMembers", mock.Anything, mock.Anything).Return([]*team.TeamMemberDTO{ - {UserID: 2, Permission: models.PERMISSION_ADMIN}, - {UserID: 3, Permission: models.PERMISSION_VIEW}, + {UserID: 2, Permission: dashboards.PERMISSION_ADMIN}, + {UserID: 3, Permission: dashboards.PERMISSION_VIEW}, }, nil).Maybe() hs.teamGuardian = manager.ProvideService(store) hs.teamPermissionsService = &actest.FakePermissionsService{} @@ -254,8 +254,8 @@ func TestUpdateTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { hs.teamService = &teamtest.FakeService{ExpectedIsMember: true} store := &database.TeamGuardianStoreMock{} store.On("GetTeamMembers", mock.Anything, mock.Anything).Return([]*team.TeamMemberDTO{ - {UserID: 2, Permission: models.PERMISSION_ADMIN}, - {UserID: 3, Permission: models.PERMISSION_VIEW}, + {UserID: 2, Permission: dashboards.PERMISSION_ADMIN}, + {UserID: 3, Permission: dashboards.PERMISSION_VIEW}, }, nil).Maybe() hs.teamGuardian = manager.ProvideService(store) hs.teamPermissionsService = &actest.FakePermissionsService{} @@ -344,8 +344,8 @@ func TestDeleteTeamMembersAPIEndpoint_LegacyAccessControl(t *testing.T) { hs.teamService = &teamtest.FakeService{ExpectedIsMember: true} store := &database.TeamGuardianStoreMock{} store.On("GetTeamMembers", mock.Anything, mock.Anything).Return([]*team.TeamMemberDTO{ - {UserID: 2, Permission: models.PERMISSION_ADMIN}, - {UserID: 3, Permission: models.PERMISSION_VIEW}, + {UserID: 2, Permission: dashboards.PERMISSION_ADMIN}, + {UserID: 3, Permission: dashboards.PERMISSION_VIEW}, }, nil).Maybe() hs.teamGuardian = manager.ProvideService(store) hs.teamPermissionsService = &actest.FakePermissionsService{} diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 3577ce501be..1423a2ea7c4 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -14,10 +14,10 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" @@ -124,7 +124,7 @@ func TestTeamAPIEndpoint(t *testing.T) { t.Run("with no real signed in user", func(t *testing.T) { logger := &logtest.Fake{} - c := &models.ReqContext{ + c := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{}, Logger: logger, @@ -141,7 +141,7 @@ func TestTeamAPIEndpoint(t *testing.T) { t.Run("with real signed in user", func(t *testing.T) { logger := &logtest.Fake{} - c := &models.ReqContext{ + c := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{UserID: 42}, Logger: logger, diff --git a/pkg/api/user.go b/pkg/api/user.go index 91345a48cfa..997b86881fa 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -29,7 +30,7 @@ import ( // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetSignedInUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetSignedInUser(c *contextmodel.ReqContext) response.Response { return hs.getUserUserProfile(c, c.UserID) } @@ -43,7 +44,7 @@ func (hs *HTTPServer) GetSignedInUser(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetUserByID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserByID(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -51,7 +52,7 @@ func (hs *HTTPServer) GetUserByID(c *models.ReqContext) response.Response { return hs.getUserUserProfile(c, id) } -func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) response.Response { +func (hs *HTTPServer) getUserUserProfile(c *contextmodel.ReqContext, userID int64) response.Response { query := user.GetUserProfileQuery{UserID: userID} userProfile, err := hs.userService.GetProfile(c.Req.Context(), &query) @@ -62,7 +63,7 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res return response.Error(500, "Failed to get user", err) } - getAuthQuery := models.GetAuthInfoQuery{UserId: userID} + getAuthQuery := login.GetAuthInfoQuery{UserId: userID} userProfile.AuthLabels = []string{} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &getAuthQuery); err == nil { authLabel := login.GetAuthProviderLabel(getAuthQuery.Result.AuthModule) @@ -86,7 +87,7 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response.Response { query := user.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")} usr, err := hs.userService.GetByLogin(c.Req.Context(), &query) if err != nil { @@ -118,7 +119,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Respo // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UpdateSignedInUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} var err error if err = web.Bind(c.Req, &cmd); err != nil { @@ -152,7 +153,7 @@ func (hs *HTTPServer) UpdateSignedInUser(c *models.ReqContext) response.Response // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) UpdateUser(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} var err error if err = web.Bind(c.Req, &cmd); err != nil { @@ -171,7 +172,7 @@ func (hs *HTTPServer) UpdateUser(c *models.ReqContext) response.Response { } // POST /api/users/:id/using/:orgId -func (hs *HTTPServer) UpdateUserActiveOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateUserActiveOrg(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -223,7 +224,7 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC } func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, error) { - getAuthQuery := models.GetAuthInfoQuery{UserId: userID} + getAuthQuery := login.GetAuthInfoQuery{UserId: userID} var err error if err = hs.authInfoService.GetAuthInfo(ctx, &getAuthQuery); err == nil { return true, nil @@ -250,7 +251,7 @@ func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, e // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetSignedInUserOrgList(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetSignedInUserOrgList(c *contextmodel.ReqContext) response.Response { return hs.getUserOrgList(c.Req.Context(), c.UserID) } @@ -265,7 +266,7 @@ func (hs *HTTPServer) GetSignedInUserOrgList(c *models.ReqContext) response.Resp // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetSignedInUserTeamList(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetSignedInUserTeamList(c *contextmodel.ReqContext) response.Response { return hs.getUserTeamList(c, c.OrgID, c.UserID) } @@ -281,7 +282,7 @@ func (hs *HTTPServer) GetSignedInUserTeamList(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetUserTeams(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserTeams(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -289,7 +290,7 @@ func (hs *HTTPServer) GetUserTeams(c *models.ReqContext) response.Response { return hs.getUserTeamList(c, c.OrgID, id) } -func (hs *HTTPServer) getUserTeamList(c *models.ReqContext, orgID int64, userID int64) response.Response { +func (hs *HTTPServer) getUserTeamList(c *contextmodel.ReqContext, orgID int64, userID int64) response.Response { query := team.GetTeamsByUserQuery{OrgID: orgID, UserID: userID, SignedInUser: c.SignedInUser} queryResult, err := hs.teamService.GetTeamsByUser(c.Req.Context(), &query) @@ -315,7 +316,7 @@ func (hs *HTTPServer) getUserTeamList(c *models.ReqContext, orgID int64, userID // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) GetUserOrgList(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserOrgList(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -365,7 +366,7 @@ func (hs *HTTPServer) validateUsingOrg(ctx context.Context, userID int64, orgID // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UserSetUsingOrg(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UserSetUsingOrg(c *contextmodel.ReqContext) response.Response { orgID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -385,7 +386,7 @@ func (hs *HTTPServer) UserSetUsingOrg(c *models.ReqContext) response.Response { } // GET /profile/switch-org/:id -func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *models.ReqContext) { +func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *contextmodel.ReqContext) { orgID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { c.JsonApiErr(http.StatusBadRequest, "id is invalid", err) @@ -420,7 +421,7 @@ func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *models.ReqContext) { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) ChangeUserPassword(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ChangeUserPassword(c *contextmodel.ReqContext) response.Response { cmd := user.ChangeUserPasswordCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -433,7 +434,7 @@ func (hs *HTTPServer) ChangeUserPassword(c *models.ReqContext) response.Response return response.Error(500, "Could not read user from database", err) } - getAuthQuery := models.GetAuthInfoQuery{UserId: user.ID} + getAuthQuery := login.GetAuthInfoQuery{UserId: user.ID} if err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &getAuthQuery); err == nil { authModule := getAuthQuery.Result.AuthModule if authModule == login.LDAPAuthModule || authModule == login.AuthProxyAuthModule { @@ -468,7 +469,7 @@ func (hs *HTTPServer) ChangeUserPassword(c *models.ReqContext) response.Response } // redirectToChangePassword handles GET /.well-known/change-password. -func redirectToChangePassword(c *models.ReqContext) { +func redirectToChangePassword(c *contextmodel.ReqContext) { c.Redirect("/profile/password", 302) } @@ -481,7 +482,7 @@ func redirectToChangePassword(c *models.ReqContext) { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) SetHelpFlag(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SetHelpFlag(c *contextmodel.ReqContext) response.Response { flag, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) @@ -511,7 +512,7 @@ func (hs *HTTPServer) SetHelpFlag(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) ClearHelpFlags(c *models.ReqContext) response.Response { +func (hs *HTTPServer) ClearHelpFlags(c *contextmodel.ReqContext) response.Response { cmd := user.SetUserHelpFlagCommand{ UserID: c.UserID, HelpFlags1: user.HelpFlags1(0), diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 2bc4c156d0d..50be2a76ed1 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -19,8 +19,9 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/logintest" @@ -89,9 +90,9 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } idToken := "testidtoken" token = token.WithExtra(map[string]interface{}{"id_token": idToken}) - login := "loginuser" - query := &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test", UserLookupParams: models.UserLookupParams{Login: &login}} - cmd := &models.UpdateAuthInfoCommand{ + userlogin := "loginuser" + query := &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test", UserLookupParams: login.UserLookupParams{Login: &userlogin}} + cmd := &login.UpdateAuthInfoCommand{ UserId: usr.ID, AuthId: query.AuthId, AuthModule: query.AuthModule, @@ -233,7 +234,7 @@ func TestHTTPServer_UpdateUser(t *testing.T) { routePattern: "/api/users/:id", cmd: updateUserCommand, fn: func(sc *scenarioContext) { - sc.authInfoService.ExpectedUserAuth = &models.UserAuth{} + sc.authInfoService.ExpectedUserAuth = &login.UserAuth{} sc.fakeReqWithParams("PUT", sc.url, map[string]string{"id": "1"}).exec() assert.Equal(t, 403, sc.resp.Code) }, @@ -255,7 +256,7 @@ func updateUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPServer) { sc.authInfoService = &logintest.AuthInfoServiceFake{} hs.authInfoService = sc.authInfoService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c @@ -294,7 +295,7 @@ func TestHTTPServer_UpdateSignedInUser(t *testing.T) { routePattern: "/api/users/", cmd: updateUserCommand, fn: func(sc *scenarioContext) { - sc.authInfoService.ExpectedUserAuth = &models.UserAuth{} + sc.authInfoService.ExpectedUserAuth = &login.UserAuth{} sc.fakeReqWithParams("PUT", sc.url, map[string]string{"id": "1"}).exec() assert.Equal(t, 403, sc.resp.Code) }, @@ -308,7 +309,7 @@ func updateSignedInUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPSer sc.authInfoService = &logintest.AuthInfoServiceFake{} hs.authInfoService = sc.authInfoService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) c.Req.Header.Add("Content-Type", "application/json") sc.context = c diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 3e12fca2d2e..dc13fa1ce77 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -8,8 +8,8 @@ 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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -27,7 +27,7 @@ import ( // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) GetUserAuthTokens(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserAuthTokens(c *contextmodel.ReqContext) response.Response { return hs.getUserAuthTokensInternal(c, c.UserID) } @@ -43,7 +43,7 @@ func (hs *HTTPServer) GetUserAuthTokens(c *models.ReqContext) response.Response // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext) response.Response { +func (hs *HTTPServer) RevokeUserAuthToken(c *contextmodel.ReqContext) response.Response { cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -72,7 +72,7 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user }) } -func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) response.Response { +func (hs *HTTPServer) getUserAuthTokensInternal(c *contextmodel.ReqContext, userID int64) response.Response { userQuery := user.GetUserByIDQuery{ID: userID} _, err := hs.userService.GetByID(c.Req.Context(), &userQuery) @@ -144,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 auth.RevokeAuthTokenCmd) response.Response { +func (hs *HTTPServer) revokeUserAuthTokenInternal(c *contextmodel.ReqContext, userID int64, cmd auth.RevokeAuthTokenCmd) response.Response { userQuery := user.GetUserByIDQuery{ID: userID} _, err := hs.userService.GetByID(c.Req.Context(), &userQuery) if err != nil { diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index 093a27011b9..a6d54f951fc 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -10,9 +10,9 @@ import ( "github.com/grafana/grafana/pkg/api/response" "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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -158,7 +158,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat sc := setupScenarioContext(t, url) sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) sc.context = c sc.context.UserID = userId @@ -185,7 +185,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte sc := setupScenarioContext(t, url) sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = userId sc.context.OrgID = testOrgID @@ -208,7 +208,7 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId } sc := setupScenarioContext(t, "/") - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID @@ -235,7 +235,7 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd auth.Rev sc := setupScenarioContext(t, "/") sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID @@ -260,7 +260,7 @@ func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *auth.Us sc := setupScenarioContext(t, "/") sc.userAuthTokenService = fakeAuthTokenService - sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { sc.context = c sc.context.UserID = testUserID sc.context.OrgID = testOrgID diff --git a/pkg/build/cmd/uploadpackages.go b/pkg/build/cmd/uploadpackages.go index e5944e03f3f..3270948ae66 100644 --- a/pkg/build/cmd/uploadpackages.go +++ b/pkg/build/cmd/uploadpackages.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/droneutil" "github.com/grafana/grafana/pkg/build/gcloud" "github.com/grafana/grafana/pkg/build/packaging" "github.com/urfave/cli/v2" @@ -22,10 +23,11 @@ const releaseBranchFolder = "prerelease" type uploadConfig struct { config.Config - edition config.Edition - versionMode config.VersionMode - gcpKey string - distDir string + edition config.Edition + versionMode config.VersionMode + gcpKey string + distDir string + versionFolder string } // UploadPackages implements the sub-command "upload-packages". @@ -94,14 +96,23 @@ func UploadPackages(c *cli.Context) error { distDir: distDir, } + event, err := droneutil.GetDroneEventFromEnv() + if err != nil { + return err + } + if cfg.edition == config.EditionEnterprise2 { - if releaseModeConfig.Buckets.ArtifactsEnterprise2 != "" { - cfg.Bucket = releaseModeConfig.Buckets.ArtifactsEnterprise2 - } else { - return fmt.Errorf("enterprise2 bucket var doesn't exist") + cfg.Bucket, err = bucketForEnterprise2(releaseModeConfig, event) + if err != nil { + return err } } + cfg.versionFolder, err = getVersionFolder(cfg, event) + if err != nil { + return err + } + if err := uploadPackages(cfg); err != nil { return cli.Exit(err.Error(), 1) } @@ -110,6 +121,40 @@ func UploadPackages(c *cli.Context) error { return nil } +// Corner case for custom enterprise2 mode +func bucketForEnterprise2(releaseModeConfig *config.BuildConfig, event string) (string, error) { + if event == config.Custom { + buildConfig, err := config.GetBuildConfig(config.ReleaseBranchMode) + if err != nil { + return "", err + } + return buildConfig.Buckets.ArtifactsEnterprise2, nil + } + + if releaseModeConfig.Buckets.ArtifactsEnterprise2 != "" { + return releaseModeConfig.Buckets.ArtifactsEnterprise2, nil + } + + return "", fmt.Errorf("enterprise2 bucket var doesn't exist") +} + +func getVersionFolder(cfg uploadConfig, event string) (string, error) { + switch cfg.versionMode { + case config.TagMode: + return releaseFolder, nil + case config.MainMode, config.DownstreamMode: + return mainFolder, nil + case config.ReleaseBranchMode: + return releaseBranchFolder, nil + default: + // Corner case for custom enterprise2 mode + if event == config.Custom && cfg.versionMode == config.Enterprise2Mode { + return releaseFolder, nil + } + return "", fmt.Errorf("unrecognized version mode: %s", cfg.versionMode) + } +} + func uploadPackages(cfg uploadConfig) error { log.Printf("Uploading Grafana packages, version %s, %s edition, %s mode...\n", cfg.Version, cfg.edition, cfg.versionMode) @@ -146,25 +191,13 @@ func uploadPackages(cfg uploadConfig) error { fpaths = append(fpaths, fpath) } - var versionFolder string - switch cfg.versionMode { - case config.TagMode: - versionFolder = releaseFolder - case config.MainMode, config.DownstreamMode: - 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) + gcsPath = fmt.Sprintf("gs://%s/%s/%s/%s", cfg.Bucket, tag, edition, cfg.versionFolder) } else { - gcsPath = fmt.Sprintf("gs://%s/%s/%s/", cfg.Bucket, edition, versionFolder) + gcsPath = fmt.Sprintf("gs://%s/%s/%s/", cfg.Bucket, edition, cfg.versionFolder) } log.Printf("Uploading %d file(s) to GCS (%s)...\n", len(fpaths), gcsPath) diff --git a/pkg/build/cmd/uploadpackages_test.go b/pkg/build/cmd/uploadpackages_test.go new file mode 100644 index 00000000000..cdb7bdab945 --- /dev/null +++ b/pkg/build/cmd/uploadpackages_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getVersionFolder(t *testing.T) { + type args struct { + cfg uploadConfig + event string + versionFolder string + } + tests := []struct { + name string + args args + err error + }{ + {"tag mode", args{uploadConfig{versionMode: config.TagMode}, "", releaseFolder}, nil}, + {"main mode", args{uploadConfig{versionMode: config.MainMode}, "", mainFolder}, nil}, + {"downstream mode", args{uploadConfig{versionMode: config.DownstreamMode}, "", mainFolder}, nil}, + {"release branch mode", args{uploadConfig{versionMode: config.ReleaseBranchMode}, "", releaseBranchFolder}, nil}, + {"enterprise pro mode", args{uploadConfig{versionMode: config.Enterprise2Mode}, config.Custom, releaseFolder}, nil}, + {"unrecognised version mode", args{uploadConfig{versionMode: "foo"}, config.Custom, ""}, errors.New("")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + versionMode, err := getVersionFolder(tt.args.cfg, tt.args.event) + if tt.err != nil { + require.Error(t, err) + } + require.Equal(t, versionMode, tt.args.versionFolder) + }) + } +} + +func Test_checkForEnterprise2Edition(t *testing.T) { + type args struct { + releaseModeConfig *config.BuildConfig + event string + } + tests := []struct { + name string + args args + want string + err error + }{ + {"event is not custom", args{releaseModeConfig: &config.BuildConfig{Buckets: config.Buckets{ArtifactsEnterprise2: "dummy"}}}, "dummy", nil}, + {"event is not custom and string is empty", args{releaseModeConfig: &config.BuildConfig{Buckets: config.Buckets{ArtifactsEnterprise2: ""}}}, "", fmt.Errorf("enterprise2 bucket var doesn't exist")}, + {"event is custom", args{releaseModeConfig: nil, event: "custom"}, "grafana-downloads-enterprise2", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := bucketForEnterprise2(tt.args.releaseModeConfig, tt.args.event) + if tt.err != nil { + require.Error(t, err) + } + assert.Equalf(t, tt.want, got, "bucketForEnterprise2(%v, %v)", tt.args.releaseModeConfig, tt.args.event) + }) + } +} diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 21959dda2cf..559a82139f4 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -102,6 +102,7 @@ import ( "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/shorturls/shorturlimpl" "github.com/grafana/grafana/pkg/services/sqlstore" + starApi "github.com/grafana/grafana/pkg/services/star/api" "github.com/grafana/grafana/pkg/services/star/starimpl" "github.com/grafana/grafana/pkg/services/store" entitystoredummy "github.com/grafana/grafana/pkg/services/store/entity/dummy" @@ -250,11 +251,11 @@ var wireSet = wire.NewSet( teamguardianDatabase.ProvideTeamGuardianStore, wire.Bind(new(teamguardian.Store), new(*teamguardianDatabase.TeamGuardianStoreImpl)), teamguardianManager.ProvideService, - dashboardservice.ProvideDashboardService, + dashboardservice.ProvideDashboardService, //DashboardServiceImpl dashboardstore.ProvideDashboardStore, - wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)), - wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)), - wire.Bind(new(dashboards.PluginService), new(*dashboardservice.DashboardServiceImpl)), + dashboardservice.ProvideSimpleDashboardService, + dashboardservice.ProvideDashboardProvisioningService, + dashboardservice.ProvideDashboardPluginService, wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)), wire.Bind(new(dashboards.FolderStore), new(*dashboardstore.DashboardStore)), dashboardimportservice.ProvideService, @@ -289,6 +290,7 @@ var wireSet = wire.NewSet( publicdashboardsStore.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*publicdashboardsStore.PublicDashboardStoreImpl)), publicdashboardsApi.ProvideApi, + starApi.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, teamimpl.ProvideService, diff --git a/pkg/codegen/jenny_gotypes.go b/pkg/codegen/jenny_gotypes.go index 85acc8b0508..57061ab4095 100644 --- a/pkg/codegen/jenny_gotypes.go +++ b/pkg/codegen/jenny_gotypes.go @@ -24,7 +24,8 @@ func (j GoTypesJenny) Generate(sfg SchemaForGen) (*codejen.File, error) { b, err := gocode.GenerateTypesOpenAPI(sfg.Schema, &gocode.TypeConfigOpenAPI{ // TODO will need to account for sanitizing e.g. dashes here at some point Config: &openapi.Config{ - Group: sfg.IsGroup, + Group: sfg.IsGroup, + RootName: sfg.Name, Config: &copenapi.Config{ ExpandReferences: j.ExpandReferences, }, diff --git a/pkg/infra/appcontext/user.go b/pkg/infra/appcontext/user.go index 604ec008b7a..d2bd57aa000 100644 --- a/pkg/infra/appcontext/user.go +++ b/pkg/infra/appcontext/user.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/user" ) @@ -33,7 +33,7 @@ func User(ctx context.Context) (*user.SignedInUser, error) { } // Set by incoming HTTP request - c, ok := ctxkey.Get(ctx).(*models.ReqContext) + c, ok := ctxkey.Get(ctx).(*contextmodel.ReqContext) if ok && c.SignedInUser != nil { return c.SignedInUser, nil } diff --git a/pkg/infra/appcontext/user_test.go b/pkg/infra/appcontext/user_test.go index 91913c692f1..5122d9044e0 100644 --- a/pkg/infra/appcontext/user_test.go +++ b/pkg/infra/appcontext/user_test.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/require" @@ -47,7 +47,7 @@ func TestUserFromContext(t *testing.T) { t.Run("should return user set by HTTP ReqContext", func(t *testing.T) { expected := testUser() - ctx := ctxkey.Set(context.Background(), &models.ReqContext{ + ctx := ctxkey.Set(context.Background(), &contextmodel.ReqContext{ SignedInUser: expected, }) actual, err := appcontext.User(ctx) diff --git a/pkg/infra/db/sqlbuilder.go b/pkg/infra/db/sqlbuilder.go index 24c2ab2b7ac..1b3c8427879 100644 --- a/pkg/infra/db/sqlbuilder.go +++ b/pkg/infra/db/sqlbuilder.go @@ -3,8 +3,8 @@ package db import ( "bytes" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/user" @@ -42,7 +42,7 @@ func (sb *SQLBuilder) AddParams(params ...interface{}) { sb.params = append(sb.params, params...) } -func (sb *SQLBuilder) WriteDashboardPermissionFilter(user *user.SignedInUser, permission models.PermissionType) { +func (sb *SQLBuilder) WriteDashboardPermissionFilter(user *user.SignedInUser, permission dashboards.PermissionType) { var ( sql string params []interface{} diff --git a/pkg/infra/db/sqlbuilder_test.go b/pkg/infra/db/sqlbuilder_test.go index 864cb16865c..8bc1d506627 100644 --- a/pkg/infra/db/sqlbuilder_test.go +++ b/pkg/infra/db/sqlbuilder_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/org" @@ -29,29 +28,29 @@ func TestIntegrationSQLBuilder(t *testing.T) { t.Run("user ACL", func(t *testing.T) { test(t, DashboardProps{}, - &DashboardPermission{User: true, Permission: models.PERMISSION_VIEW}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, ) test(t, DashboardProps{}, - &DashboardPermission{User: true, Permission: models.PERMISSION_VIEW}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, ) test(t, DashboardProps{}, - &DashboardPermission{User: true, Permission: models.PERMISSION_EDIT}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, ) test(t, DashboardProps{}, - &DashboardPermission{User: true, Permission: models.PERMISSION_VIEW}, - Search{RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, ) }) @@ -59,29 +58,29 @@ func TestIntegrationSQLBuilder(t *testing.T) { t.Run("role ACL", func(t *testing.T) { test(t, DashboardProps{}, - &DashboardPermission{Role: org.RoleViewer, Permission: models.PERMISSION_VIEW}, - Search{UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, ) test(t, DashboardProps{}, - &DashboardPermission{Role: org.RoleViewer, Permission: models.PERMISSION_VIEW}, - Search{UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, ) test(t, DashboardProps{}, - &DashboardPermission{Role: org.RoleEditor, Permission: models.PERMISSION_VIEW}, - Search{UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, ) test(t, DashboardProps{}, - &DashboardPermission{Role: org.RoleEditor, Permission: models.PERMISSION_VIEW}, - Search{UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, ) }) @@ -89,29 +88,29 @@ func TestIntegrationSQLBuilder(t *testing.T) { t.Run("team ACL", func(t *testing.T) { test(t, DashboardProps{}, - &DashboardPermission{Team: true, Permission: models.PERMISSION_VIEW}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_VIEW}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, ) test(t, DashboardProps{}, - &DashboardPermission{Team: true, Permission: models.PERMISSION_VIEW}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, ) test(t, DashboardProps{}, - &DashboardPermission{Team: true, Permission: models.PERMISSION_EDIT}, - Search{UserFromACL: true, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, ) test(t, DashboardProps{}, - &DashboardPermission{Team: true, Permission: models.PERMISSION_EDIT}, - Search{UserFromACL: false, RequiredPermission: models.PERMISSION_EDIT}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: false, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, ) }) @@ -120,28 +119,28 @@ func TestIntegrationSQLBuilder(t *testing.T) { test(t, DashboardProps{}, nil, - Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_VIEW}, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, ) test(t, DashboardProps{OrgId: -1}, nil, - Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_VIEW}, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, ) test(t, DashboardProps{OrgId: -1}, nil, - Search{OrgId: -1, UsersOrgRole: org.RoleEditor, RequiredPermission: models.PERMISSION_EDIT}, + Search{OrgId: -1, UsersOrgRole: org.RoleEditor, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, ) test(t, DashboardProps{OrgId: -1}, nil, - Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: models.PERMISSION_EDIT}, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, ) }) @@ -159,13 +158,13 @@ type DashboardPermission struct { User bool Team bool Role org.RoleType - Permission models.PermissionType + Permission dashboards.PermissionType } type Search struct { UsersOrgRole org.RoleType UserFromACL bool - RequiredPermission models.PermissionType + RequiredPermission dashboards.PermissionType OrgId int64 } @@ -401,11 +400,11 @@ func updateDashboardACL(t *testing.T, sqlStore *sqlstore.SQLStore, dashboardID i item.Created = time.Now() item.Updated = time.Now() if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing + return dashboards.ErrDashboardACLInfoMissing } if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty + return dashboards.ErrDashboardPermissionDashboardEmpty } sess.Nullable("user_id", "team_id") diff --git a/pkg/infra/usagestats/service/api.go b/pkg/infra/usagestats/service/api.go index 836f73d3202..46ebed121aa 100644 --- a/pkg/infra/usagestats/service/api.go +++ b/pkg/infra/usagestats/service/api.go @@ -6,8 +6,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) const rootUrl = "/api/admin" @@ -20,7 +20,7 @@ func (uss *UsageStats) registerAPIEndpoints() { }) } -func (uss *UsageStats) getUsageReportPreview(ctx *models.ReqContext) response.Response { +func (uss *UsageStats) getUsageReportPreview(ctx *contextmodel.ReqContext) response.Response { usageReport, err := uss.GetUsageReport(ctx.Req.Context()) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get usage report", err) diff --git a/pkg/infra/usagestats/service/api_test.go b/pkg/infra/usagestats/service/api_test.go index 94acc44e189..92ab183954f 100644 --- a/pkg/infra/usagestats/service/api_test.go +++ b/pkg/infra/usagestats/service/api_test.go @@ -11,8 +11,8 @@ import ( "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -93,7 +93,7 @@ type testContext struct { func contextProvider(tc *testContext) web.Handler { return func(c *web.Context) { signedIn := tc.user != nil - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: c, SignedInUser: tc.user, IsSignedIn: signedIn, diff --git a/pkg/kinds/dashboard/dashboard_types_gen.go b/pkg/kinds/dashboard/dashboard_types_gen.go index c14456ce35a..7ed218331cc 100644 --- a/pkg/kinds/dashboard/dashboard_types_gen.go +++ b/pkg/kinds/dashboard/dashboard_types_gen.go @@ -10,6 +10,13 @@ package dashboard +// Defines values for Style. +const ( + StyleDark Style = "dark" + + StyleLight Style = "light" +) + // Defines values for CursorSync. const ( CursorSyncN0 CursorSync = 0 @@ -151,22 +158,6 @@ const ( VariableTypeTextbox VariableType = "textbox" ) -// Defines values for Style. -const ( - StyleDark Style = "dark" - - StyleLight Style = "light" -) - -// Defines values for Timezone. -const ( - TimezoneBrowser Timezone = "browser" - - TimezoneEmpty Timezone = "" - - TimezoneUtc Timezone = "utc" -) - // TODO docs // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts type AnnotationQuery struct { @@ -207,6 +198,108 @@ type AnnotationTarget struct { Type string `json:"type"` } +// Dashboard defines model for Dashboard. +type Dashboard struct { + // TODO docs + Annotations *struct { + List *[]AnnotationQuery `json:"list,omitempty"` + } `json:"annotations,omitempty"` + + // Description of dashboard. + Description *string `json:"description,omitempty"` + + // Whether a dashboard is editable or not. + Editable bool `json:"editable"` + + // The month that the fiscal year starts on. 0 = January, 11 = December + FiscalYearStartMonth *int `json:"fiscalYearStartMonth,omitempty"` + GnetId *string `json:"gnetId,omitempty"` + + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + GraphTooltip CursorSync `json:"graphTooltip"` + + // Unique numeric identifier for the dashboard. + // TODO must isolate or remove identifiers local to a Grafana instance...? + Id *int64 `json:"id,omitempty"` + + // TODO docs + Links *[]Link `json:"links,omitempty"` + + // TODO docs + LiveNow *bool `json:"liveNow,omitempty"` + Panels *[]interface{} `json:"panels,omitempty"` + + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + Refresh *interface{} `json:"refresh,omitempty"` + + // Version of the current dashboard data + Revision int `json:"revision"` + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + // TODO this is the existing schema numbering system. It will be replaced by Thema's themaVersion + SchemaVersion int `json:"schemaVersion"` + + // TODO docs + Snapshot *Snapshot `json:"snapshot,omitempty"` + + // Theme of dashboard. + Style Style `json:"style"` + + // Tags associated with dashboard. + Tags *[]string `json:"tags,omitempty"` + + // TODO docs + Templating *struct { + List *[]VariableModel `json:"list,omitempty"` + } `json:"templating,omitempty"` + + // Time range for dashboard, e.g. last 6 hours, last 7 days, etc + Time *struct { + From string `json:"from"` + To string `json:"to"` + } `json:"time,omitempty"` + + // TODO docs + // TODO this appears to be spread all over in the frontend. Concepts will likely need tidying in tandem with schema changes + Timepicker *struct { + // Whether timepicker is collapsed or not. + Collapse bool `json:"collapse"` + + // Whether timepicker is enabled or not. + Enable bool `json:"enable"` + + // Whether timepicker is visible or not. + Hidden bool `json:"hidden"` + + // Selectable intervals for auto-refresh. + RefreshIntervals []string `json:"refresh_intervals"` + + // TODO docs + TimeOptions []string `json:"time_options"` + } `json:"timepicker,omitempty"` + + // Timezone of dashboard. Accepts IANA TZDB zone ID or "browser" or "utc". + Timezone *string `json:"timezone,omitempty"` + + // Title of dashboard. + Title *string `json:"title,omitempty"` + + // Unique dashboard identifier that can be generated by anyone. string (8-40) + Uid *string `json:"uid,omitempty"` + + // Version of the dashboard, incremented each time the dashboard is updated. + Version *int `json:"version,omitempty"` + + // TODO docs + WeekStart *string `json:"weekStart,omitempty"` +} + +// Theme of dashboard. +type Style string + // 0 for no shared crosshair or tooltip (default). // 1 for shared crosshair. // 2 for shared crosshair AND shared tooltip. @@ -670,108 +763,3 @@ type VariableModel struct { // TODO docs // TODO this implies some wider pattern/discriminated union, probably? type VariableType string - -// Dashboard defines model for dashboard. -type Dashboard struct { - // TODO docs - Annotations *struct { - List *[]AnnotationQuery `json:"list,omitempty"` - } `json:"annotations,omitempty"` - - // Description of dashboard. - Description *string `json:"description,omitempty"` - - // Whether a dashboard is editable or not. - Editable bool `json:"editable"` - - // TODO docs - FiscalYearStartMonth *int `json:"fiscalYearStartMonth,omitempty"` - GnetId *string `json:"gnetId,omitempty"` - - // 0 for no shared crosshair or tooltip (default). - // 1 for shared crosshair. - // 2 for shared crosshair AND shared tooltip. - GraphTooltip CursorSync `json:"graphTooltip"` - - // Unique numeric identifier for the dashboard. - // TODO must isolate or remove identifiers local to a Grafana instance...? - Id *int64 `json:"id,omitempty"` - - // TODO docs - Links *[]Link `json:"links,omitempty"` - - // TODO docs - LiveNow *bool `json:"liveNow,omitempty"` - Panels *[]interface{} `json:"panels,omitempty"` - - // TODO docs - Refresh *interface{} `json:"refresh,omitempty"` - - // Version of the current dashboard data - Revision int `json:"revision"` - - // Version of the JSON schema, incremented each time a Grafana update brings - // changes to said schema. - // TODO this is the existing schema numbering system. It will be replaced by Thema's themaVersion - SchemaVersion int `json:"schemaVersion"` - - // TODO docs - Snapshot *Snapshot `json:"snapshot,omitempty"` - - // Theme of dashboard. - Style Style `json:"style"` - - // Tags associated with dashboard. - Tags *[]string `json:"tags,omitempty"` - - // TODO docs - Templating *struct { - List *[]VariableModel `json:"list,omitempty"` - } `json:"templating,omitempty"` - - // Time range for dashboard, e.g. last 6 hours, last 7 days, etc - Time *struct { - From string `json:"from"` - To string `json:"to"` - } `json:"time,omitempty"` - - // TODO docs - // TODO this appears to be spread all over in the frontend. Concepts will likely need tidying in tandem with schema changes - Timepicker *struct { - // Whether timepicker is collapsed or not. - Collapse bool `json:"collapse"` - - // Whether timepicker is enabled or not. - Enable bool `json:"enable"` - - // Whether timepicker is visible or not. - Hidden bool `json:"hidden"` - - // Selectable intervals for auto-refresh. - RefreshIntervals []string `json:"refresh_intervals"` - - // TODO docs - TimeOptions []string `json:"time_options"` - } `json:"timepicker,omitempty"` - - // Timezone of dashboard, - Timezone *Timezone `json:"timezone,omitempty"` - - // Title of dashboard. - Title *string `json:"title,omitempty"` - - // Unique dashboard identifier that can be generated by anyone. string (8-40) - Uid *string `json:"uid,omitempty"` - - // Version of the dashboard, incremented each time the dashboard is updated. - Version *int `json:"version,omitempty"` - - // TODO docs - WeekStart *string `json:"weekStart,omitempty"` -} - -// Theme of dashboard. -type Style string - -// Timezone of dashboard, -type Timezone string diff --git a/pkg/kinds/playlist/playlist_types_gen.go b/pkg/kinds/playlist/playlist_types_gen.go index c8f3608e883..b46a38b247b 100644 --- a/pkg/kinds/playlist/playlist_types_gen.go +++ b/pkg/kinds/playlist/playlist_types_gen.go @@ -19,6 +19,24 @@ const ( ItemTypeDashboardByUid ItemType = "dashboard_by_uid" ) +// Playlist defines model for Playlist. +type Playlist struct { + // Interval sets the time between switching views in a playlist. + // FIXME: Is this based on a standardized format or what options are available? Can datemath be used? + Interval string `json:"interval"` + + // The ordered list of items that the playlist will iterate over. + // FIXME! This should not be optional, but changing it makes the godegen awkward + Items *[]Item `json:"items,omitempty"` + + // Name of the playlist. + Name string `json:"name"` + + // Unique playlist identifier. Generated on creation, either by the + // creator of the playlist of by the application. + Uid string `json:"uid"` +} + // Item defines model for Item. type Item struct { // Title is an unused property -- it will be removed in the future @@ -40,21 +58,3 @@ type Item struct { // Type of the item. type ItemType string - -// Playlist defines model for playlist. -type Playlist struct { - // Interval sets the time between switching views in a playlist. - // FIXME: Is this based on a standardized format or what options are available? Can datemath be used? - Interval string `json:"interval"` - - // The ordered list of items that the playlist will iterate over. - // FIXME! This should not be optional, but changing it makes the godegen awkward - Items *[]Item `json:"items,omitempty"` - - // Name of the playlist. - Name string `json:"name"` - - // Unique playlist identifier. Generated on creation, either by the - // creator of the playlist of by the application. - Uid string `json:"uid"` -} diff --git a/pkg/kinds/preferences/preferences_kind_gen.go b/pkg/kinds/preferences/preferences_kind_gen.go new file mode 100644 index 00000000000..4ffd0a15688 --- /dev/null +++ b/pkg/kinds/preferences/preferences_kind_gen.go @@ -0,0 +1,113 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package preferences + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/preferences" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*Preferences] + jcodec vmux.Codec + valmux vmux.ValueMux[*Preferences] + decl kindsys.Decl[kindsys.CoreProperties] +} + +// type guard +var _ kindsys.Core = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind(rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) + tsch, err := thema.BindType[*Preferences](cursch, &Preferences{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jcodec = vmux.NewJSONCodec("preferences.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "preferences" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "preferences" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Preferences] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of Preferences. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*Preferences, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Properties.Maturity +} + +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// preferences declaration in .cue files. +func (k *Kind) Decl() kindsys.Decl[kindsys.CoreProperties] { + return k.decl +} + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreProperties], +// representing the static properties declared in the preferences kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/preferences/preferences_types_gen.go b/pkg/kinds/preferences/preferences_types_gen.go new file mode 100644 index 00000000000..1d5d7909e28 --- /dev/null +++ b/pkg/kinds/preferences/preferences_types_gen.go @@ -0,0 +1,37 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// GoTypesJenny +// LatestJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package preferences + +// Preferences defines model for Preferences. +type Preferences struct { + // UID for the home dashboard + HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` + + // Selected language (beta) + Language *string `json:"language,omitempty"` + QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` + + // light, dark, empty is default + Theme *string `json:"theme,omitempty"` + + // The timezone selection + // TODO: this should use the timezone defined in common + Timezone *string `json:"timezone,omitempty"` + + // day of the week (sunday, monday, etc) + WeekStart *string `json:"weekStart,omitempty"` +} + +// QueryHistoryPreference defines model for QueryHistoryPreference. +type QueryHistoryPreference struct { + // one of: '' | 'query' | 'starred'; + HomeTab *string `json:"homeTab,omitempty"` +} diff --git a/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go b/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go new file mode 100644 index 00000000000..70add7f3adb --- /dev/null +++ b/pkg/kinds/publicdashboard/publicdashboard_kind_gen.go @@ -0,0 +1,113 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package publicdashboard + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/publicdashboard" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*PublicDashboard] + jcodec vmux.Codec + valmux vmux.ValueMux[*PublicDashboard] + decl kindsys.Decl[kindsys.CoreProperties] +} + +// type guard +var _ kindsys.Core = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind(rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) + tsch, err := thema.BindType[*PublicDashboard](cursch, &PublicDashboard{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jcodec = vmux.NewJSONCodec("publicdashboard.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "publicdashboard" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "publicdashboard" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*PublicDashboard] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of PublicDashboard. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*PublicDashboard, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Properties.Maturity +} + +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// publicdashboard declaration in .cue files. +func (k *Kind) Decl() kindsys.Decl[kindsys.CoreProperties] { + return k.decl +} + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreProperties], +// representing the static properties declared in the publicdashboard kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/publicdashboard/publicdashboard_types_gen.go b/pkg/kinds/publicdashboard/publicdashboard_types_gen.go new file mode 100644 index 00000000000..80da0c962c1 --- /dev/null +++ b/pkg/kinds/publicdashboard/publicdashboard_types_gen.go @@ -0,0 +1,32 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// GoTypesJenny +// LatestJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package publicdashboard + +// PublicDashboard defines model for PublicDashboard. +type PublicDashboard struct { + // Unique public access token + AccessToken *string `json:"accessToken,omitempty"` + + // Flag that indicates if annotations are enabled + AnnotationsEnabled bool `json:"annotationsEnabled"` + + // Dashboard unique identifier referenced by this public dashboard + DashboardUid string `json:"dashboardUid"` + + // Flag that indicates if the public dashboard is enabled + IsEnabled bool `json:"isEnabled"` + + // Flag that indicates if the time range picker is enabled + TimeSelectionEnabled bool `json:"timeSelectionEnabled"` + + // Unique public dashboard identifier + Uid string `json:"uid"` +} diff --git a/pkg/kinds/serviceaccount/serviceaccount_kind_gen.go b/pkg/kinds/serviceaccount/serviceaccount_kind_gen.go new file mode 100644 index 00000000000..8f3e18add3f --- /dev/null +++ b/pkg/kinds/serviceaccount/serviceaccount_kind_gen.go @@ -0,0 +1,113 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package serviceaccount + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/serviceaccount" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*ServiceAccount] + jcodec vmux.Codec + valmux vmux.ValueMux[*ServiceAccount] + decl kindsys.Decl[kindsys.CoreProperties] +} + +// type guard +var _ kindsys.Core = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind(rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) + tsch, err := thema.BindType[*ServiceAccount](cursch, &ServiceAccount{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jcodec = vmux.NewJSONCodec("serviceaccount.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "serviceaccount" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "serviceaccount" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*ServiceAccount] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of ServiceAccount. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*ServiceAccount, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Properties.Maturity +} + +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// serviceaccount declaration in .cue files. +func (k *Kind) Decl() kindsys.Decl[kindsys.CoreProperties] { + return k.decl +} + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreProperties], +// representing the static properties declared in the serviceaccount kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/serviceaccount/serviceaccount_types_gen.go b/pkg/kinds/serviceaccount/serviceaccount_types_gen.go new file mode 100644 index 00000000000..9e80405af2f --- /dev/null +++ b/pkg/kinds/serviceaccount/serviceaccount_types_gen.go @@ -0,0 +1,64 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// GoTypesJenny +// LatestJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package serviceaccount + +// Defines values for OrgRole. +const ( + OrgRoleAdmin OrgRole = "Admin" + + OrgRoleEditor OrgRole = "Editor" + + OrgRoleViewer OrgRole = "Viewer" +) + +// OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'. +type OrgRole string + +// ServiceAccount defines model for ServiceAccount. +type ServiceAccount struct { + // AccessControl metadata associated with a given resource. + AccessControl map[string]bool `json:"accessControl,omitempty"` + + // AvatarUrl is the service account's avatar URL. It allows the frontend to display a picture in front + // of the service account. + AvatarUrl string `json:"avatarUrl"` + + // Created indicates when the service account was created. + Created *int64 `json:"created,omitempty"` + + // ID is the unique identifier of the service account in the database. + Id int64 `json:"id"` + + // IsDisabled indicates if the service account is disabled. + IsDisabled bool `json:"isDisabled"` + + // Login of the service account. + Login string `json:"login"` + + // Name of the service account. + Name string `json:"name"` + + // OrgId is the ID of an organisation the service account belongs to. + OrgId int64 `json:"orgId"` + + // OrgRole is a Grafana Organization Role which can be 'Viewer', 'Editor', 'Admin'. + Role OrgRole `json:"role"` + + // Teams is a list of teams the service account belongs to. + Teams *[]string `json:"teams,omitempty"` + + // Tokens is the number of active tokens for the service account. + // Tokens are used to authenticate the service account against Grafana. + Tokens int64 `json:"tokens"` + + // Updated indicates when the service account was updated. + Updated *int64 `json:"updated,omitempty"` +} diff --git a/pkg/kinds/team/team_types_gen.go b/pkg/kinds/team/team_types_gen.go index 4c58ef6a557..c619d03ab8d 100644 --- a/pkg/kinds/team/team_types_gen.go +++ b/pkg/kinds/team/team_types_gen.go @@ -24,7 +24,7 @@ const ( // Permission defines model for Permission. type Permission int -// Team defines model for team. +// Team defines model for Team. type Team struct { // AccessControl metadata associated with a given resource. AccessControl map[string]bool `json:"accessControl,omitempty"` diff --git a/pkg/kindsys/report.json b/pkg/kindsys/report.json index 67d72700ab8..d6f4b88460e 100644 --- a/pkg/kindsys/report.json +++ b/pkg/kindsys/report.json @@ -283,7 +283,7 @@ 0, 0 ], - "grafanaMaturityCount": 144, + "grafanaMaturityCount": 139, "lineageIsGroup": false, "links": { "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/dashboard/schema-reference", @@ -1009,7 +1009,9 @@ }, "nodegraphpanelcfg": { "category": "composable", - "codeowners": [], + "codeowners": [ + "grafana/observability-traces-and-profiling" + ], "currentVersion": [ 0, 0 @@ -1017,13 +1019,13 @@ "grafanaMaturityCount": 0, "lineageIsGroup": true, "links": { - "docs": "n/a", + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/nodegraphpanelcfg/schema-reference", "go": "n/a", - "schema": "n/a", - "ts": "n/a" + "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/nodeGraph/panelcfg.cue", + "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/nodeGraph/panelcfg.gen.ts" }, "machineName": "nodegraphpanelcfg", - "maturity": "planned", + "maturity": "experimental", "name": "NodeGraphPanelCfg", "pluralMachineName": "nodegraphpanelcfgs", "pluralName": "NodeGraphPanelCfgs", @@ -1211,6 +1213,32 @@ "pluralName": "PostgreSQLDataSourceCfgs", "schemaInterface": "DataSourceCfg" }, + "preferences": { + "category": "core", + "codeowners": [ + "grafana/grafana-as-code", + "grafana/grafana-bi-squad", + "grafana/plugins-platform-frontend", + "grafana/user-essentials" + ], + "currentVersion": [ + 0, + 0 + ], + "grafanaMaturityCount": 0, + "lineageIsGroup": false, + "links": { + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/preferences/schema-reference", + "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/preferences", + "schema": "https://github.com/grafana/grafana/tree/main/kinds/preferences/preferences_kind.cue", + "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/preferences/x/preferences_types.gen.ts" + }, + "machineName": "preferences", + "maturity": "merged", + "name": "Preferences", + "pluralMachineName": "preferencess", + "pluralName": "Preferencess" + }, "prometheusdataquery": { "category": "composable", "codeowners": [], @@ -1255,6 +1283,32 @@ "pluralName": "PrometheusDataSourceCfgs", "schemaInterface": "DataSourceCfg" }, + "publicdashboard": { + "category": "core", + "codeowners": [ + "grafana/grafana-as-code", + "grafana/grafana-bi-squad", + "grafana/plugins-platform-frontend", + "grafana/user-essentials" + ], + "currentVersion": [ + 0, + 0 + ], + "grafanaMaturityCount": 0, + "lineageIsGroup": false, + "links": { + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/publicdashboard/schema-reference", + "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/publicdashboard", + "schema": "https://github.com/grafana/grafana/tree/main/kinds/publicdashboard/publicdashboard_kind.cue", + "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/publicdashboard/x/publicdashboard_types.gen.ts" + }, + "machineName": "publicdashboard", + "maturity": "merged", + "name": "PublicDashboard", + "pluralMachineName": "publicdashboards", + "pluralName": "PublicDashboards" + }, "query": { "category": "core", "codeowners": [], @@ -1299,24 +1353,53 @@ }, "serviceaccount": { "category": "core", - "codeowners": [], + "codeowners": [ + "grafana/grafana-as-code", + "grafana/grafana-bi-squad", + "grafana/plugins-platform-frontend", + "grafana/user-essentials" + ], + "currentVersion": [ + 0, + 0 + ], + "grafanaMaturityCount": 9, + "lineageIsGroup": false, + "links": { + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/serviceaccount/schema-reference", + "go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/serviceaccount", + "schema": "https://github.com/grafana/grafana/tree/main/kinds/serviceaccount/serviceaccount_kind.cue", + "ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/serviceaccount/x/serviceaccount_types.gen.ts" + }, + "machineName": "serviceaccount", + "maturity": "merged", + "name": "ServiceAccount", + "pluralMachineName": "serviceaccounts", + "pluralName": "ServiceAccounts" + }, + "statetimelinepanelcfg": { + "category": "composable", + "codeowners": [ + "grafana/grafana-bi-squad" + ], "currentVersion": [ 0, 0 ], "grafanaMaturityCount": 0, - "lineageIsGroup": false, + "lineageIsGroup": true, "links": { - "docs": "n/a", + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/statetimelinepanelcfg/schema-reference", "go": "n/a", - "schema": "n/a", - "ts": "n/a" + "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/state-timeline/panelcfg.cue", + "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/state-timeline/panelcfg.gen.ts" }, - "machineName": "serviceaccount", - "maturity": "planned", - "name": "ServiceAccount", - "pluralMachineName": "serviceaccounts", - "pluralName": "ServiceAccounts" + "machineName": "statetimelinepanelcfg", + "maturity": "experimental", + "name": "StateTimelinePanelCfg", + "pluralMachineName": "statetimelinepanelcfgs", + "pluralName": "StateTimelinePanelCfgs", + "schemaInterface": "PanelCfg" }, "statpanelcfg": { "category": "composable", @@ -1342,6 +1425,30 @@ "pluralName": "StatPanelCfgs", "schemaInterface": "PanelCfg" }, + "statushistorypanelcfg": { + "category": "composable", + "codeowners": [ + "grafana/grafana-bi-squad" + ], + "currentVersion": [ + 0, + 0 + ], + "grafanaMaturityCount": 0, + "lineageIsGroup": true, + "links": { + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/statushistorypanelcfg/schema-reference", + "go": "n/a", + "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/status-history/panelcfg.cue", + "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/status-history/panelcfg.gen.ts" + }, + "machineName": "statushistorypanelcfg", + "maturity": "experimental", + "name": "StatusHistoryPanelCfg", + "pluralMachineName": "statushistorypanelcfgs", + "pluralName": "StatusHistoryPanelCfgs", + "schemaInterface": "PanelCfg" + }, "tableoldpanelcfg": { "category": "composable", "codeowners": [], @@ -1434,9 +1541,11 @@ "pluralName": "TempoDataSourceCfgs", "schemaInterface": "DataSourceCfg" }, - "testdatadbdataquery": { + "testdatadataquery": { "category": "composable", - "codeowners": [], + "codeowners": [ + "grafana/plugins-platform-frontend" + ], "currentVersion": [ 0, 0 @@ -1444,19 +1553,19 @@ "grafanaMaturityCount": 0, "lineageIsGroup": false, "links": { - "docs": "n/a", - "go": "n/a", - "schema": "n/a", - "ts": "n/a" + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/testdatadataquery/schema-reference", + "go": "https://github.com/grafana/grafana/tree/main/pkg/tsdb/testdata/kinds/dataquery/types_dataquery_gen.go", + "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/testdata/dataquery.cue", + "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/datasource/testdata/dataquery.gen.ts" }, - "machineName": "testdatadbdataquery", - "maturity": "planned", - "name": "TestDataDBDataQuery", - "pluralMachineName": "testdatadbdataquerys", - "pluralName": "TestDataDBDataQuerys", + "machineName": "testdatadataquery", + "maturity": "experimental", + "name": "TestDataDataQuery", + "pluralMachineName": "testdatadataquerys", + "pluralName": "TestDataDataQuerys", "schemaInterface": "DataQuery" }, - "testdatadbdatasourcecfg": { + "testdatadatasourcecfg": { "category": "composable", "codeowners": [], "currentVersion": [ @@ -1471,11 +1580,11 @@ "schema": "n/a", "ts": "n/a" }, - "machineName": "testdatadbdatasourcecfg", + "machineName": "testdatadatasourcecfg", "maturity": "planned", - "name": "TestDataDBDataSourceCfg", - "pluralMachineName": "testdatadbdatasourcecfgs", - "pluralName": "TestDataDBDataSourceCfgs", + "name": "TestDataDataSourceCfg", + "pluralMachineName": "testdatadatasourcecfgs", + "pluralName": "TestDataDataSourceCfgs", "schemaInterface": "DataSourceCfg" }, "textpanelcfg": { @@ -1590,7 +1699,9 @@ }, "xychartpanelcfg": { "category": "composable", - "codeowners": [], + "codeowners": [ + "grafana/grafana-bi-squad" + ], "currentVersion": [ 0, 0 @@ -1598,13 +1709,13 @@ "grafanaMaturityCount": 0, "lineageIsGroup": true, "links": { - "docs": "n/a", + "docs": "https://grafana.com/docs/grafana/next/developers/kinds/composable/xychartpanelcfg/schema-reference", "go": "n/a", - "schema": "n/a", - "ts": "n/a" + "schema": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/xychart/panelcfg.cue", + "ts": "https://github.com/grafana/grafana/tree/main/public/app/plugins/panel/xychart/panelcfg.gen.ts" }, "machineName": "xychartpanelcfg", - "maturity": "planned", + "maturity": "experimental", "name": "XYChartPanelCfg", "pluralMachineName": "xychartpanelcfgs", "pluralName": "XYChartPanelCfgs", @@ -1711,12 +1822,14 @@ "postgresqldatasourcecfg", "prometheusdataquery", "prometheusdatasourcecfg", + "statetimelinepanelcfg", "statpanelcfg", + "statushistorypanelcfg", "tableoldpanelcfg", "tempodataquery", "tempodatasourcecfg", - "testdatadbdataquery", - "testdatadbdatasourcecfg", + "testdatadataquery", + "testdatadatasourcecfg", "textpanelcfg", "tracespanelcfg", "welcomepanelcfg", @@ -1724,7 +1837,7 @@ "zipkindataquery", "zipkindatasourcecfg" ], - "count": 63 + "count": 65 }, "core": { "name": "core", @@ -1734,6 +1847,8 @@ "datasource", "folder", "playlist", + "preferences", + "publicdashboard", "query", "queryhistory", "serviceaccount", @@ -1741,7 +1856,7 @@ "thumb", "user" ], - "count": 11 + "count": 13 } }, "maturity": { @@ -1756,11 +1871,16 @@ "gaugepanelcfg", "histogrampanelcfg", "newspanelcfg", + "nodegraphpanelcfg", "piechartpanelcfg", + "statetimelinepanelcfg", "statpanelcfg", - "textpanelcfg" + "statushistorypanelcfg", + "testdatadataquery", + "textpanelcfg", + "xychartpanelcfg" ], - "count": 11 + "count": 16 }, "mature": { "name": "mature", @@ -1772,9 +1892,12 @@ "items": [ "alertgroupspanelcfg", "playlist", + "preferences", + "publicdashboard", + "serviceaccount", "team" ], - "count": 3 + "count": 6 }, "planned": { "name": "planned", @@ -1815,7 +1938,6 @@ "microsoftsqlserverdatasourcecfg", "mysqldataquery", "mysqldatasourcecfg", - "nodegraphpanelcfg", "parcadataquery", "parcadatasourcecfg", "phlaredataquery", @@ -1826,21 +1948,18 @@ "prometheusdatasourcecfg", "query", "queryhistory", - "serviceaccount", "tableoldpanelcfg", "tempodataquery", "tempodatasourcecfg", - "testdatadbdataquery", - "testdatadbdatasourcecfg", + "testdatadatasourcecfg", "thumb", "tracespanelcfg", "user", "welcomepanelcfg", - "xychartpanelcfg", "zipkindataquery", "zipkindatasourcecfg" ], - "count": 60 + "count": 56 }, "stable": { "name": "stable", diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 382b4b4f288..0a22319f0f9 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/loginattempt" @@ -30,7 +29,7 @@ var ( var loginLogger = log.New("login") type Authenticator interface { - AuthenticateUser(context.Context, *models.LoginUserQuery) error + AuthenticateUser(context.Context, *login.LoginUserQuery) error } type AuthenticatorService struct { @@ -49,7 +48,7 @@ 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 { +func (a *AuthenticatorService) AuthenticateUser(ctx context.Context, query *login.LoginUserQuery) error { ok, err := a.loginAttemptService.Validate(ctx, query.Username) if err != nil { return err diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go index edc8f4ed3d8..8720c6e0ea4 100644 --- a/pkg/login/auth_test.go +++ b/pkg/login/auth_test.go @@ -5,15 +5,15 @@ import ( "errors" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "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/loginattempttest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestAuthenticateUser(t *testing.T) { @@ -23,7 +23,7 @@ func TestAuthenticateUser(t *testing.T) { loginAttemptService := &loginattempttest.FakeLoginAttemptService{ExpectedValid: true} a := AuthenticatorService{loginAttemptService: loginAttemptService, loginService: &logintest.LoginServiceFake{}} - err := a.AuthenticateUser(context.Background(), &models.LoginUserQuery{ + err := a.AuthenticateUser(context.Background(), &login.LoginUserQuery{ Username: "user", Password: "", }) @@ -180,7 +180,7 @@ func TestAuthenticateUser(t *testing.T) { } type authScenarioContext struct { - loginUserQuery *models.LoginUserQuery + loginUserQuery *login.LoginUserQuery grafanaLoginWasCalled bool ldapLoginWasCalled bool } @@ -188,14 +188,14 @@ type authScenarioContext struct { type authScenarioFunc func(sc *authScenarioContext) func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { - loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery, _ user.Service) error { + loginUsingGrafanaDB = func(ctx context.Context, query *login.LoginUserQuery, _ user.Service) error { sc.grafanaLoginWasCalled = true return err } } func mockLoginUsingLDAP(enabled bool, err error, sc *authScenarioContext) { - loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery, _ login.Service) (bool, error) { + loginUsingLDAP = func(ctx context.Context, query *login.LoginUserQuery, _ login.Service) (bool, error) { sc.ldapLoginWasCalled = true return enabled, err } @@ -209,7 +209,7 @@ func authScenario(t *testing.T, desc string, fn authScenarioFunc) { origLoginUsingLDAP := loginUsingLDAP cfg := setting.Cfg{DisableLogin: false} sc := &authScenarioContext{ - loginUserQuery: &models.LoginUserQuery{ + loginUserQuery: &login.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go index 64bc19362e1..3ef14a39f79 100644 --- a/pkg/login/grafana_login.go +++ b/pkg/login/grafana_login.go @@ -4,7 +4,7 @@ import ( "context" "crypto/subtle" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" ) @@ -21,7 +21,7 @@ var validatePassword = func(providedPassword string, userPassword string, userSa return nil } -var loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery, userService user.Service) error { +var loginUsingGrafanaDB = func(ctx context.Context, query *login.LoginUserQuery, userService user.Service) error { userQuery := user.GetUserByLoginQuery{LoginOrEmail: query.Username} user, err := userService.GetByLogin(ctx, &userQuery) diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go index 531f6a1dcc3..cd68019de97 100644 --- a/pkg/login/grafana_login_test.go +++ b/pkg/login/grafana_login_test.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" ) @@ -59,7 +59,7 @@ func TestLoginUsingGrafanaDB(t *testing.T) { type grafanaLoginScenarioContext struct { store db.DB userService *usertest.FakeUserService - loginUserQuery *models.LoginUserQuery + loginUserQuery *login.LoginUserQuery validatePasswordCalled bool } @@ -73,7 +73,7 @@ func grafanaLoginScenario(t *testing.T, desc string, fn grafanaLoginScenarioFunc sc := &grafanaLoginScenarioContext{ store: dbtest.NewFakeDB(), - loginUserQuery: &models.LoginUserQuery{ + loginUserQuery: &login.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index bf2b23a9e24..a2f0b4f3321 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" @@ -27,7 +26,7 @@ var ldapLogger = log.New("login.ldap") // loginUsingLDAP logs in user using LDAP. It returns whether LDAP is enabled and optional error and query arg will be // populated with the logged in user if successful. -var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery, loginService login.Service) (bool, error) { +var loginUsingLDAP = func(ctx context.Context, query *login.LoginUserQuery, loginService login.Service) (bool, error) { enabled := isLDAPEnabled() if !enabled { @@ -54,11 +53,11 @@ var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery, log return true, err } - upsert := &models.UpsertUserCommand{ + upsert := &login.UpsertUserCommand{ ReqContext: query.ReqContext, ExternalUser: externalUser, SignupAllowed: setting.LDAPAllowSignup, - UserLookupParams: models.UserLookupParams{ + UserLookupParams: login.UserLookupParams{ Login: &externalUser.Login, Email: &externalUser.Email, UserID: nil, diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go index 4503b272e92..ac4dc47205c 100644 --- a/pkg/login/ldap_login_test.go +++ b/pkg/login/ldap_login_test.go @@ -5,13 +5,14 @@ import ( "errors" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "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/multildap" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) var errTest = errors.New("test error") @@ -62,8 +63,8 @@ func (auth *mockAuth) Ping() ([]*multildap.ServerStatus, error) { return nil, nil } -func (auth *mockAuth) Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, +func (auth *mockAuth) Login(query *login.LoginUserQuery) ( + *login.ExternalUserInfo, error, ) { auth.loginCalled = true @@ -76,14 +77,14 @@ func (auth *mockAuth) Login(query *models.LoginUserQuery) ( } func (auth *mockAuth) Users(logins []string) ( - []*models.ExternalUserInfo, + []*login.ExternalUserInfo, error, ) { return nil, nil } func (auth *mockAuth) User(login string) ( - *models.ExternalUserInfo, + *login.ExternalUserInfo, ldap.ServerConfig, error, ) { @@ -111,7 +112,7 @@ func mockLDAPAuthenticator(valid bool) *mockAuth { } type LDAPLoginScenarioContext struct { - loginUserQuery *models.LoginUserQuery + loginUserQuery *login.LoginUserQuery LDAPAuthenticatorMock *mockAuth } @@ -124,7 +125,7 @@ func LDAPLoginScenario(t *testing.T, desc string, fn LDAPLoginScenarioFunc) { mock := &mockAuth{} sc := &LDAPLoginScenarioContext{ - loginUserQuery: &models.LoginUserQuery{ + loginUserQuery: &login.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index ca56f2888a7..f170d6d9194 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -8,6 +8,8 @@ import ( "regexp" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/models/roletype" ) type SocialGithub struct { @@ -15,6 +17,7 @@ type SocialGithub struct { allowedOrganizations []string apiUrl string teamIds []int + skipOrgRoleSync bool } type GithubTeam struct { @@ -201,14 +204,25 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi teams := convertToGroupList(teamMemberships) - role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams, true) - if s.roleAttributeStrict && !role.IsValid() { - return nil, &InvalidBasicRoleError{idP: "Github", assignedRole: string(role)} + var role roletype.RoleType + var isGrafanaAdmin *bool = nil + + if !s.skipOrgRoleSync { + var grafanaAdmin bool + role, grafanaAdmin = s.extractRoleAndAdmin(response.Body, teams, true) + + if s.roleAttributeStrict && !role.IsValid() { + return nil, &InvalidBasicRoleError{idP: "Github", assignedRole: string(role)} + } + + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin + } } - var isGrafanaAdmin *bool = nil - if s.allowAssignGrafanaAdmin { - isGrafanaAdmin = &grafanaAdmin + // we skip allowing assignment of GrafanaAdmin if skipOrgRoleSync is present + if s.allowAssignGrafanaAdmin && s.skipOrgRoleSync { + s.log.Debug("allowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other") } userInfo := &BasicUserInfo{ diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index cdb400b15ea..9d92e21cd35 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -112,11 +112,14 @@ const testGHUserJSON = `{ }` func TestSocialGitHub_UserInfo(t *testing.T) { + var boolPointer *bool tests := []struct { name string userRawJSON string userTeamsRawJSON string settingAutoAssignOrgRole string + settingAllowGrafanaAdmin bool + settingSkipOrgRoleSync bool roleAttributePath string autoAssignOrgRole string want *BasicUserInfo @@ -167,6 +170,38 @@ func TestSocialGitHub_UserInfo(t *testing.T) { Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, + { + name: "Should be empty role if setting skipOrgRoleSync is set to true", + roleAttributePath: "contains(groups[*], '@github/justice-league') && 'Editor' || 'Viewer'", + settingSkipOrgRoleSync: true, + userRawJSON: testGHUserJSON, + userTeamsRawJSON: testGHUserTeamsJSON, + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + }, + }, + { + name: "Should return nil pointer if allowGrafanaAdmin and skipOrgRoleSync setting is set to true", + roleAttributePath: "contains(groups[*], '@github/justice-league') && 'Editor' || 'Viewer'", + settingSkipOrgRoleSync: true, + settingAllowGrafanaAdmin: true, + userRawJSON: testGHUserJSON, + userTeamsRawJSON: testGHUserTeamsJSON, + want: &BasicUserInfo{ + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + IsGrafanaAdmin: boolPointer, + }, + }, { // Case that's going to change with Grafana 10 name: "No fallback to default org role (will change in Grafana 10)", roleAttributePath: "", @@ -208,6 +243,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) { allowedOrganizations: []string{}, apiUrl: server.URL + "/user", teamIds: []int{}, + skipOrgRoleSync: tt.settingSkipOrgRoleSync, } token := &oauth2.Token{ diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index c7cd9d51d13..5f0f48ee960 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -7,12 +7,15 @@ import ( "regexp" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/models/roletype" ) type SocialGitlab struct { *SocialBase - allowedGroups []string - apiUrl string + allowedGroups []string + apiUrl string + skipOrgRoleSync bool } func (s *SocialGitlab) IsGroupMember(groups []string) bool { @@ -107,14 +110,21 @@ func (s *SocialGitlab) UserInfo(client *http.Client, _ *oauth2.Token) (*BasicUse groups := s.GetGroups(client) - role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups, true) - if s.roleAttributeStrict && !role.IsValid() { - return nil, &InvalidBasicRoleError{idP: "Gitlab", assignedRole: string(role)} - } - + var role roletype.RoleType var isGrafanaAdmin *bool = nil - if s.allowAssignGrafanaAdmin { - isGrafanaAdmin = &grafanaAdmin + if !s.skipOrgRoleSync { + var grafanaAdmin bool + role, grafanaAdmin = s.extractRoleAndAdmin(response.Body, groups, true) + if s.roleAttributeStrict && !role.IsValid() { + return nil, &InvalidBasicRoleError{idP: "Gitlab", assignedRole: string(role)} + } + + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin + } + } + if s.allowAssignGrafanaAdmin && s.skipOrgRoleSync { + s.log.Debug("allowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other") } userInfo := &BasicUserInfo{ diff --git a/pkg/login/social/gitlab_oauth_test.go b/pkg/login/social/gitlab_oauth_test.go index e95b4ed326e..188b23e7d44 100644 --- a/pkg/login/social/gitlab_oauth_test.go +++ b/pkg/login/social/gitlab_oauth_test.go @@ -27,16 +27,19 @@ const ( ) func TestSocialGitlab_UserInfo(t *testing.T) { + var nilPointer *bool provider := SocialGitlab{ SocialBase: &SocialBase{ log: newLogger("gitlab_oauth_test", "debug"), }, + skipOrgRoleSync: false, } type conf struct { AllowAssignGrafanaAdmin bool RoleAttributeStrict bool AutoAssignOrgRole org.RoleType + SkipOrgRoleSync bool } tests := []struct { @@ -83,6 +86,17 @@ func TestSocialGitlab_UserInfo(t *testing.T) { ExpectedRole: "Editor", ExpectedGrafanaAdmin: falseBoolPtr(), }, + { + Name: "Should not sync role, return empty role and nil pointer for GrafanaAdmin for skip org role sync set to true", + Cfg: conf{SkipOrgRoleSync: true}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{viewerGroup, editorGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "", + ExpectedGrafanaAdmin: nilPointer, + }, { // Case that's going to change with Grafana 10 Name: "No fallback to default org role (will change in Grafana 10)", Cfg: conf{AutoAssignOrgRole: org.RoleViewer}, @@ -126,6 +140,7 @@ func TestSocialGitlab_UserInfo(t *testing.T) { provider.allowAssignGrafanaAdmin = test.Cfg.AllowAssignGrafanaAdmin provider.autoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) provider.roleAttributeStrict = test.Cfg.RoleAttributeStrict + provider.skipOrgRoleSync = test.Cfg.SkipOrgRoleSync t.Run(test.Name, func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index bc3f68eaea1..af56b006c4b 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -60,7 +60,9 @@ type OAuthInfo struct { AutoLogin bool } -func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *SocialService { +func ProvideService(cfg *setting.Cfg, + features *featuremgmt.FeatureManager, +) *SocialService { ss := SocialService{ cfg: cfg, oAuthProvider: make(map[string]*OAuthInfo), @@ -146,15 +148,17 @@ func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *Soc apiUrl: info.ApiUrl, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), + skipOrgRoleSync: cfg.GithubSkipOrgRoleSync, } } // GitLab. if name == "gitlab" { ss.socialMap["gitlab"] = &SocialGitlab{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), - apiUrl: info.ApiUrl, - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), + apiUrl: info.ApiUrl, + allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), + skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync, } } @@ -225,6 +229,7 @@ func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *Soc } } } + return &ss } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 1ae4b33b80b..8ac2e457c7d 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "errors" + "net/http" "net/url" "regexp" "strconv" @@ -9,10 +10,11 @@ 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/plugins" "github.com/grafana/grafana/pkg/services/auth" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/setting" @@ -25,7 +27,7 @@ type AuthOptions struct { ReqNoAnonynmous bool } -func accessForbidden(c *models.ReqContext) { +func accessForbidden(c *contextmodel.ReqContext) { if c.IsApiRequest() { c.JsonApiErr(403, "Permission denied", nil) return @@ -34,9 +36,9 @@ func accessForbidden(c *models.ReqContext) { c.Redirect(setting.AppSubUrl + "/") } -func notAuthorized(c *models.ReqContext) { +func notAuthorized(c *contextmodel.ReqContext) { if c.IsApiRequest() { - c.JsonApiErr(401, "Unauthorized", nil) + c.WriteErrOrFallback(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), c.LookupTokenErr) return } @@ -44,7 +46,7 @@ func notAuthorized(c *models.ReqContext) { c.Redirect(setting.AppSubUrl + "/login") } -func tokenRevoked(c *models.ReqContext, err *auth.TokenRevokedError) { +func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { if c.IsApiRequest() { c.JSON(401, map[string]interface{}{ "message": "Token revoked", @@ -60,7 +62,7 @@ func tokenRevoked(c *models.ReqContext, err *auth.TokenRevokedError) { c.Redirect(setting.AppSubUrl + "/login") } -func writeRedirectCookie(c *models.ReqContext) { +func writeRedirectCookie(c *contextmodel.ReqContext) { redirectTo := c.Req.RequestURI if setting.AppSubUrl != "" && !strings.HasPrefix(redirectTo, setting.AppSubUrl) { redirectTo = setting.AppSubUrl + c.Req.RequestURI @@ -81,14 +83,14 @@ func removeForceLoginParams(str string) string { return forceLoginParamsRegexp.ReplaceAllString(str, "") } -func EnsureEditorOrViewerCanEdit(c *models.ReqContext) { +func EnsureEditorOrViewerCanEdit(c *contextmodel.ReqContext) { if !c.SignedInUser.HasRole(org.RoleEditor) && !setting.ViewersCanEdit { accessForbidden(c) } } -func CanAdminPlugins(cfg *setting.Cfg) func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func CanAdminPlugins(cfg *setting.Cfg) func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { if !plugins.ReqCanAdminPlugins(cfg)(c) { accessForbidden(c) return @@ -97,7 +99,7 @@ func CanAdminPlugins(cfg *setting.Cfg) func(c *models.ReqContext) { } func RoleAuth(roles ...org.RoleType) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { ok := false for _, role := range roles { if role == c.OrgRole { @@ -112,7 +114,7 @@ func RoleAuth(roles ...org.RoleType) web.Handler { } func Auth(options *AuthOptions) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { forceLogin := false if c.AllowAnonymous { forceLogin = shouldForceLogin(c) @@ -151,7 +153,7 @@ func Auth(options *AuthOptions) web.Handler { // Intended for when feature flags open up access to APIs that // are otherwise only available to admins. func AdminOrEditorAndFeatureEnabled(enabled bool) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if c.OrgRole == org.RoleAdmin { return } @@ -167,7 +169,7 @@ func AdminOrEditorAndFeatureEnabled(enabled bool) web.Handler { // SnapshotPublicModeOrSignedIn creates a middleware that allows access // if snapshot public mode is enabled or if user is signed in. func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if cfg.SnapshotPublicMode { return } @@ -179,7 +181,7 @@ func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler { } } -func ReqNotSignedIn(c *models.ReqContext) { +func ReqNotSignedIn(c *contextmodel.ReqContext) { if c.IsSignedIn { c.Redirect(setting.AppSubUrl + "/") } @@ -188,7 +190,7 @@ func ReqNotSignedIn(c *models.ReqContext) { // NoAuth creates a middleware that doesn't require any authentication. // If forceLogin param is set it will redirect the user to the login page. func NoAuth() web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if shouldForceLogin(c) { notAuthorized(c) return @@ -198,7 +200,7 @@ func NoAuth() web.Handler { // shouldForceLogin checks if user should be enforced to login. // Returns true if forceLogin parameter is set. -func shouldForceLogin(c *models.ReqContext) bool { +func shouldForceLogin(c *contextmodel.ReqContext) bool { forceLogin := false forceLoginParam, err := strconv.ParseBool(c.Req.URL.Query().Get("forceLogin")) if err == nil { @@ -208,18 +210,19 @@ func shouldForceLogin(c *models.ReqContext) bool { return forceLogin } -func OrgAdminDashOrFolderAdminOrTeamAdmin(ss db.DB, ds dashboards.DashboardService, ts team.Service) func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func OrgAdminDashOrFolderAdminOrTeamAdmin(ss db.DB, ds dashboards.DashboardService, ts team.Service) func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { if c.OrgRole == org.RoleAdmin { return } - hasAdminPermissionInDashOrFoldersQuery := models.HasAdminPermissionInDashboardsOrFoldersQuery{SignedInUser: c.SignedInUser} - if err := ds.HasAdminPermissionInDashboardsOrFolders(c.Req.Context(), &hasAdminPermissionInDashOrFoldersQuery); err != nil { + hasAdminPermissionInDashOrFoldersQuery := folder.HasAdminPermissionInDashboardsOrFoldersQuery{SignedInUser: c.SignedInUser} + hasAdminPermissionInDashOrFoldersQueryResult, err := ds.HasAdminPermissionInDashboardsOrFolders(c.Req.Context(), &hasAdminPermissionInDashOrFoldersQuery) + if err != nil { c.JsonApiErr(500, "Failed to check if user is a folder admin", err) } - if hasAdminPermissionInDashOrFoldersQuery.Result { + if hasAdminPermissionInDashOrFoldersQueryResult { return } diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index bcef0a863e6..9e12a2ffd64 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" ) @@ -87,7 +87,7 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func( t *testing.T, sc *scenarioContext) { - sc.m.Get("/api/snapshot", func(c *models.ReqContext) { + sc.m.Get("/api/snapshot", func(c *contextmodel.ReqContext) { c.IsSignedIn = false }, SnapshotPublicModeOrSignedIn(sc.cfg), sc.defaultHandler) sc.fakeReq("GET", "/api/snapshot").exec() @@ -96,7 +96,7 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "Snapshot public mode disabled and authenticated request should return 200", func( t *testing.T, sc *scenarioContext) { - sc.m.Get("/api/snapshot", func(c *models.ReqContext) { + sc.m.Get("/api/snapshot", func(c *contextmodel.ReqContext) { c.IsSignedIn = true }, SnapshotPublicModeOrSignedIn(sc.cfg), sc.defaultHandler) sc.fakeReq("GET", "/api/snapshot").exec() diff --git a/pkg/middleware/cookies/cookies.go b/pkg/middleware/cookies/cookies.go index 3d068cff2aa..915e396a8e6 100644 --- a/pkg/middleware/cookies/cookies.go +++ b/pkg/middleware/cookies/cookies.go @@ -5,7 +5,7 @@ import ( "net/url" "time" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" ) @@ -55,7 +55,7 @@ func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, g http.SetCookie(w, &cookie) } -func WriteSessionCookie(ctx *models.ReqContext, cfg *setting.Cfg, value string, maxLifetime time.Duration) { +func WriteSessionCookie(ctx *contextmodel.ReqContext, cfg *setting.Cfg, value string, maxLifetime time.Duration) { if cfg.Env == setting.Dev { ctx.Logger.Info("New token", "unhashed token", value) } diff --git a/pkg/middleware/dashboard_redirect.go b/pkg/middleware/dashboard_redirect.go index 496f8243e81..5409cfa2179 100644 --- a/pkg/middleware/dashboard_redirect.go +++ b/pkg/middleware/dashboard_redirect.go @@ -4,14 +4,14 @@ import ( "fmt" "strings" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" ) // In Grafana v7.0 we changed panel edit & view query parameters. // This middleware tries to detect those old url parameters and direct to the new url query params -func RedirectFromLegacyPanelEditURL(cfg *setting.Cfg) func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func RedirectFromLegacyPanelEditURL(cfg *setting.Cfg) func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { queryParams := c.Req.URL.Query() panelID, hasPanelID := queryParams["panelId"] diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 4366ca45e45..83d78b82ec9 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -21,8 +21,8 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -89,7 +89,7 @@ var sensitiveQueryStrings = [...]string{ "auth_token", } -func SanitizeURL(ctx *models.ReqContext, s string) string { +func SanitizeURL(ctx *contextmodel.ReqContext, s string) string { if s == "" { return s } diff --git a/pkg/middleware/logger_test.go b/pkg/middleware/logger_test.go index 1b74bb7246c..fd17c87787e 100644 --- a/pkg/middleware/logger_test.go +++ b/pkg/middleware/logger_test.go @@ -4,13 +4,13 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/stretchr/testify/assert" ) func Test_sanitizeURL(t *testing.T) { type args struct { - ctx *models.ReqContext + ctx *contextmodel.ReqContext s string } tests := []struct { @@ -21,7 +21,7 @@ func Test_sanitizeURL(t *testing.T) { { name: "Receiving empty string should return it", args: args{ - ctx: &models.ReqContext{ + ctx: &contextmodel.ReqContext{ Logger: log.New("test.logger"), }, s: "", @@ -31,7 +31,7 @@ func Test_sanitizeURL(t *testing.T) { { name: "Receiving valid URL string should return it parsed", args: args{ - ctx: &models.ReqContext{ + ctx: &contextmodel.ReqContext{ Logger: log.New("test.logger"), }, s: "https://grafana.com/", @@ -41,7 +41,7 @@ func Test_sanitizeURL(t *testing.T) { { name: "Receiving invalid URL string should return empty string", args: args{ - ctx: &models.ReqContext{ + ctx: &contextmodel.ReqContext{ Logger: log.New("test.logger"), }, s: "this is not a valid URL", diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 940fd5e36c1..2bc0893e5b7 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -21,7 +21,7 @@ var ( ReqOrgAdmin = RoleAuth(org.RoleAdmin) ) -func HandleNoCacheHeader(ctx *models.ReqContext) { +func HandleNoCacheHeader(ctx *contextmodel.ReqContext) { ctx.SkipCache = ctx.Req.Header.Get("X-Grafana-NoCache") == "true" } @@ -69,9 +69,9 @@ func addSecurityHeaders(w web.ResponseWriter, cfg *setting.Cfg) { } func addNoCacheHeaders(w web.ResponseWriter) { - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "-1") + w.Header().Set("Cache-Control", "no-store") + w.Header().Del("Pragma") + w.Header().Del("Expires") } func addXFrameOptionsDenyHeader(w web.ResponseWriter) { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index cd522c12136..ac6d530749d 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -24,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeytest" "github.com/grafana/grafana/pkg/services/auth" @@ -33,7 +32,9 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" + loginsvc "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/navtree" @@ -128,7 +129,7 @@ func TestMiddleWareContentSecurityPolicyHeaders(t *testing.T) { } func TestMiddlewareContext(t *testing.T) { - const noCache = "no-cache" + const noStore = "no-store" configureJWTAuthHeader := func(cfg *setting.Cfg) { cfg.JWTAuthEnabled = true @@ -147,9 +148,9 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "middleware should add Cache-Control header for requests to API", func(t *testing.T, sc *scenarioContext) { sc.fakeReq("GET", "/api/search").exec() - assert.Equal(t, noCache, sc.resp.Header().Get("Cache-Control")) - assert.Equal(t, noCache, sc.resp.Header().Get("Pragma")) - assert.Equal(t, "-1", sc.resp.Header().Get("Expires")) + assert.Equal(t, noStore, sc.resp.Header().Get("Cache-Control")) + assert.Empty(t, sc.resp.Header().Get("Pragma")) + assert.Empty(t, sc.resp.Header().Get("Expires")) }) middlewareScenario(t, "middleware should not add Cache-Control header for requests to datasource proxy API", func( @@ -162,7 +163,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "middleware should add Cache-Control header for requests with HTML response", func( t *testing.T, sc *scenarioContext) { - sc.handlerFunc = func(c *models.ReqContext) { + sc.handlerFunc = func(c *contextmodel.ReqContext) { t.Log("Handler called") data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, @@ -175,9 +176,9 @@ func TestMiddlewareContext(t *testing.T) { } sc.fakeReq("GET", "/").exec() require.Equal(t, 200, sc.resp.Code) - assert.Equal(t, noCache, sc.resp.Header().Get("Cache-Control")) - assert.Equal(t, noCache, sc.resp.Header().Get("Pragma")) - assert.Equal(t, "-1", sc.resp.Header().Get("Expires")) + assert.Equal(t, noStore, sc.resp.Header().Get("Cache-Control")) + assert.Empty(t, sc.resp.Header().Get("Pragma")) + assert.Empty(t, sc.resp.Header().Get("Expires")) }) middlewareScenario(t, "middleware should add X-Frame-Options header with deny for request when not allowing embedding", func( @@ -436,7 +437,7 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(11 * time.Second)} + sc.oauthTokenService.ExpectedAuthUser = &loginsvc.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(11 * time.Second)} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return &auth.UserToken{ @@ -464,7 +465,7 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") signedInUser := &user.SignedInUser{OrgID: 2, UserID: userID} sc.userService.ExpectedSignedInUser = signedInUser - sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{ + sc.oauthTokenService.ExpectedAuthUser = &loginsvc.UserAuth{ UserId: userID, OAuthExpiry: fakeGetTime()().Add(-1 * time.Second), OAuthAccessToken: "access_token", @@ -499,7 +500,7 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") 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.oauthTokenService.ExpectedAuthUser = &loginsvc.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(-5 * time.Second), OAuthRefreshToken: "refreshtoken"} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return &auth.UserToken{ @@ -526,7 +527,7 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID} + sc.oauthTokenService.ExpectedAuthUser = &loginsvc.UserAuth{UserId: userID} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return &auth.UserToken{ @@ -609,7 +610,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Should respect auto signup option", func(t *testing.T, sc *scenarioContext) { var actualAuthProxyAutoSignUp *bool = nil - sc.loginService.ExpectedUserFunc = func(cmd *models.UpsertUserCommand) *user.User { + sc.loginService.ExpectedUserFunc = func(cmd *loginsvc.UpsertUserCommand) *user.User { actualAuthProxyAutoSignUp = &cmd.SignupAllowed return nil } @@ -651,7 +652,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Should assign role from header to default org", func(t *testing.T, sc *scenarioContext) { var storedRoleInfo map[int64]org.RoleType = nil - sc.loginService.ExpectedUserFunc = func(cmd *models.UpsertUserCommand) *user.User { + sc.loginService.ExpectedUserFunc = func(cmd *loginsvc.UpsertUserCommand) *user.User { storedRoleInfo = cmd.ExternalUser.OrgRoles sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: defaultOrgId, UserID: userID, OrgRole: storedRoleInfo[defaultOrgId]} return &user.User{ID: userID} @@ -674,7 +675,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Should NOT assign role from header to non-default org", func(t *testing.T, sc *scenarioContext) { var storedRoleInfo map[int64]org.RoleType = nil - sc.loginService.ExpectedUserFunc = func(cmd *models.UpsertUserCommand) *user.User { + sc.loginService.ExpectedUserFunc = func(cmd *loginsvc.UpsertUserCommand) *user.User { storedRoleInfo = cmd.ExternalUser.OrgRoles sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: orgID, UserID: userID, OrgRole: storedRoleInfo[orgID]} return &user.User{ID: userID} @@ -721,7 +722,7 @@ func TestMiddlewareContext(t *testing.T) { body := "key=value" sc.req.Body = io.NopCloser(strings.NewReader(body)) - sc.handlerFunc = func(c *models.ReqContext) { + sc.handlerFunc = func(c *contextmodel.ReqContext) { t.Log("Handler called") defer func() { err := c.Req.Body.Close() @@ -745,7 +746,7 @@ func TestMiddlewareContext(t *testing.T) { body := "key=value" sc.req.Body = io.NopCloser(strings.NewReader(body)) - sc.handlerFunc = func(c *models.ReqContext) { + sc.handlerFunc = func(c *contextmodel.ReqContext) { t.Log("Handler called") defer func() { err := c.Req.Body.Close() @@ -889,7 +890,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.jwtAuthService = ctxHdlr.JWTAuthService.(*jwt.FakeJWTService) sc.remoteCacheService = ctxHdlr.RemoteCache - sc.defaultHandler = func(c *models.ReqContext) { + sc.defaultHandler = func(c *contextmodel.ReqContext) { require.NotNil(t, c) t.Log("Default HTTP handler called") sc.context = c diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 7a0689ff11d..31568e7fd5a 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -3,7 +3,7 @@ package middleware import ( "fmt" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) @@ -15,7 +15,7 @@ func Quota(quotaService quota.Service) func(string) web.Handler { } //https://open.spotify.com/track/7bZSoBEAEEUsGEuLOf94Jm?si=T1Tdju5qRSmmR0zph_6RBw fuuuuunky return func(targetSrv string) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { limitReached, err := quotaService.QuotaReached(c, quota.TargetSrv(targetSrv)) if err != nil { c.JsonApiErr(500, "Failed to get quota", err) diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 1a8fe8537c5..8a4251c7917 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth/authtest" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -42,7 +42,7 @@ func TestRecoveryMiddleware(t *testing.T) { }) } -func panicHandler(c *models.ReqContext) { +func panicHandler(c *contextmodel.ReqContext) { panic("Handler has panicked") } @@ -73,7 +73,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { // mock out gc goroutine sc.m.Use(OrgRedirect(cfg, sc.userService)) - sc.defaultHandler = func(c *models.ReqContext) { + sc.defaultHandler = func(c *contextmodel.ReqContext) { sc.context = c if sc.handlerFunc != nil { sc.handlerFunc(sc.context) diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index 2c27fcf7ae2..6caa397c4e8 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -11,12 +11,12 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "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/authtest" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -27,7 +27,7 @@ import ( type scenarioContext struct { t *testing.T m *web.Mux - context *models.ReqContext + context *contextmodel.ReqContext resp *httptest.ResponseRecorder apiKey string authHeader string @@ -80,7 +80,7 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { req, err := http.NewRequest(method, url, nil) require.NoError(sc.t, err) - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: web.FromContext(req.Context()), } sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx)) @@ -102,7 +102,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map req.URL.RawQuery = q.Encode() require.NoError(sc.t, err) - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: web.FromContext(req.Context()), } sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx)) @@ -147,4 +147,4 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(t *testing.T, c *scenarioContext) -type handlerFunc func(c *models.ReqContext) +type handlerFunc func(c *contextmodel.ReqContext) diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go index bf9d2db9c3d..3b75552cd58 100644 --- a/pkg/middleware/validate_host.go +++ b/pkg/middleware/validate_host.go @@ -3,13 +3,13 @@ package middleware import ( "strings" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) func ValidateHostHeader(cfg *setting.Cfg) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { // ignore local render calls if c.IsRenderCall { return diff --git a/pkg/models/folders.go b/pkg/models/folders.go deleted file mode 100644 index f0951ad8b94..00000000000 --- a/pkg/models/folders.go +++ /dev/null @@ -1,60 +0,0 @@ -package models - -import ( - "time" - - "github.com/grafana/grafana/pkg/services/user" -) - -type Folder struct { - Id int64 - Uid string - Title string - Url string - Version int - - Created time.Time - Updated time.Time - - UpdatedBy int64 - CreatedBy int64 - HasACL bool -} - -// NewFolder creates a new Folder -func NewFolder(title string) *Folder { - folder := &Folder{} - folder.Title = title - folder.Created = time.Now() - folder.Updated = time.Now() - return folder -} - -// -// COMMANDS -// - -type CreateFolderCommand struct { - Uid string `json:"uid"` - Title string `json:"title"` - - Result *Folder `json:"-"` -} - -type MoveFolderCommand struct { - ParentUID *string `json:"parentUid"` -} - -// -// QUERIES -// - -type HasEditPermissionInFoldersQuery struct { - SignedInUser *user.SignedInUser - Result bool -} - -type HasAdminPermissionInDashboardsOrFoldersQuery struct { - SignedInUser *user.SignedInUser - Result bool -} diff --git a/pkg/models/search.go b/pkg/models/search.go index 6629620b99a..6bf91aca3fe 100644 --- a/pkg/models/search.go +++ b/pkg/models/search.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" - "github.com/grafana/grafana/pkg/services/user" ) type SortOption struct { @@ -20,26 +19,6 @@ type SortOptionFilter interface { searchstore.FilterOrderBy } -type FindPersistedDashboardsQuery struct { - Title string - OrgId int64 - SignedInUser *user.SignedInUser - IsStarred bool - DashboardIds []int64 - DashboardUIDs []string - Type string - FolderIds []int64 - Tags []string - Limit int64 - Page int64 - Permission PermissionType - Sort SortOption - - Filters []interface{} - - Result HitList -} - type HitType string const ( diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go deleted file mode 100644 index ec1f70e75a0..00000000000 --- a/pkg/models/user_auth.go +++ /dev/null @@ -1,136 +0,0 @@ -package models - -import ( - "fmt" - "time" - - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - - "golang.org/x/oauth2" -) - -type UserAuth struct { - Id int64 - UserId int64 - AuthModule string - AuthId string - Created time.Time - OAuthAccessToken string - OAuthRefreshToken string - OAuthIdToken string - OAuthTokenType string - OAuthExpiry time.Time -} - -type ExternalUserInfo struct { - OAuthToken *oauth2.Token - AuthModule string - AuthId string - UserId int64 - Email string - Login string - Name string - Groups []string - OrgRoles map[int64]org.RoleType - IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync) - IsDisabled bool -} - -func (e *ExternalUserInfo) String() string { - return fmt.Sprintf("%+v", *e) -} - -type LoginInfo struct { - AuthModule string - User *user.User - ExternalUser ExternalUserInfo - LoginUsername string - HTTPStatus int - Error error -} - -// RequestURIKey is used as key to save request URI in contexts -// (used for the Enterprise auditing feature) -type RequestURIKey struct{} - -// --------------------- -// COMMANDS - -type UpsertUserCommand struct { - ReqContext *ReqContext - ExternalUser *ExternalUserInfo - UserLookupParams - SignupAllowed bool - - Result *user.User -} - -type SetAuthInfoCommand struct { - AuthModule string - AuthId string - UserId int64 - OAuthToken *oauth2.Token -} - -type UpdateAuthInfoCommand struct { - AuthModule string - AuthId string - UserId int64 - OAuthToken *oauth2.Token -} - -type DeleteAuthInfoCommand struct { - UserAuth *UserAuth -} - -// ---------------------- -// QUERIES - -type LoginUserQuery struct { - ReqContext *ReqContext - Username string - Password string - User *user.User - IpAddress string - AuthModule string - Cfg *setting.Cfg -} - -type GetUserByAuthInfoQuery struct { - AuthModule string - AuthId string - UserLookupParams -} - -type UserLookupParams struct { - // Describes lookup order as well - UserID *int64 // if set, will try to find the user by id - Email *string // if set, will try to find the user by email - Login *string // if set, will try to find the user by login -} - -type GetExternalUserInfoByLoginQuery struct { - LoginOrEmail string - - Result *ExternalUserInfo -} - -type GetAuthInfoQuery struct { - UserId int64 - AuthModule string - AuthId string - - Result *UserAuth -} - -type GetUserLabelsQuery struct { - UserIDs []int64 -} - -type TeamOrgGroupDTO struct { - TeamName string `json:"teamName"` - OrgName string `json:"orgName"` - GroupDN string `json:"groupDN"` -} diff --git a/pkg/plugins/accesscontrol.go b/pkg/plugins/accesscontrol.go index fef15b2404b..cc8c1b59e65 100644 --- a/pkg/plugins/accesscontrol.go +++ b/pkg/plugins/accesscontrol.go @@ -1,8 +1,8 @@ package plugins import ( - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" ) @@ -22,9 +22,9 @@ var ( AdminAccessEvaluator = ac.EvalAny(ac.EvalPermission(ActionWrite), ac.EvalPermission(ActionInstall)) ) -func ReqCanAdminPlugins(cfg *setting.Cfg) func(rc *models.ReqContext) bool { +func ReqCanAdminPlugins(cfg *setting.Cfg) func(rc *contextmodel.ReqContext) bool { // Legacy handler that protects access to the Configuration > Plugins page - return func(rc *models.ReqContext) bool { + return func(rc *contextmodel.ReqContext) bool { return rc.OrgRole == org.RoleAdmin || cfg.PluginAdminEnabled && rc.IsGrafanaAdmin } } diff --git a/pkg/plugins/codegen/jenny_plugingotypes.go b/pkg/plugins/codegen/jenny_plugingotypes.go index 59103eed880..cc34c610352 100644 --- a/pkg/plugins/codegen/jenny_plugingotypes.go +++ b/pkg/plugins/codegen/jenny_plugingotypes.go @@ -51,6 +51,11 @@ func (j *pgoJenny) Generate(decl *pfs.PluginDecl) (*codejen.File, error) { } pluginfolder := filepath.Base(decl.PluginPath) + // hardcoded exception for testdata datasource, ONLY because "testdata" is basically a + // language-reserved keyword for Go + if pluginfolder == "testdata" { + pluginfolder = "testdatasource" + } filename := fmt.Sprintf("types_%s_gen.go", slotname) return codejen.NewFile(filepath.Join(j.root, pluginfolder, "kinds", slotname, filename), byt, j), nil } diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 1c97e241819..c73b71e3b5d 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -30,6 +30,8 @@ type Cfg struct { BuildVersion string // TODO Remove LogDatasourceRequests bool + + PluginsCDNURLTemplate string } func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg { @@ -63,6 +65,7 @@ func NewCfg(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg { AWSAssumeRoleEnabled: aws.KeyValue("assume_role_enabled").MustBool(grafanaCfg.AWSAssumeRoleEnabled), Azure: grafanaCfg.Azure, LogDatasourceRequests: grafanaCfg.IsFeatureToggleEnabled(featuremgmt.FlagDatasourceLogger), + PluginsCDNURLTemplate: grafanaCfg.PluginsCDNURLTemplate, } } diff --git a/pkg/plugins/manager/client/clienttest/clienttest.go b/pkg/plugins/manager/client/clienttest/clienttest.go index f88d07d3376..64dcd0a78e1 100644 --- a/pkg/plugins/manager/client/clienttest/clienttest.go +++ b/pkg/plugins/manager/client/clienttest/clienttest.go @@ -7,10 +7,10 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/require" @@ -130,7 +130,7 @@ type ClientDecoratorTest struct { TestClient *TestClient Middlewares []plugins.ClientMiddleware Decorator *client.Decorator - ReqContext *models.ReqContext + ReqContext *contextmodel.ReqContext QueryDataReq *backend.QueryDataRequest QueryDataCtx context.Context CallResourceReq *backend.CallResourceRequest @@ -181,7 +181,7 @@ func NewClientDecoratorTest(t *testing.T, opts ...ClientDecoratorTestOption) *Cl func WithReqContext(req *http.Request, user *user.SignedInUser) ClientDecoratorTestOption { return ClientDecoratorTestOption(func(cdt *ClientDecoratorTest) { if cdt.ReqContext == nil { - cdt.ReqContext = &models.ReqContext{ + cdt.ReqContext = &contextmodel.ReqContext{ Context: &web.Context{}, SignedInUser: user, } diff --git a/pkg/plugins/manager/loader/assetpath/assetpath.go b/pkg/plugins/manager/loader/assetpath/assetpath.go new file mode 100644 index 00000000000..57ebc7557da --- /dev/null +++ b/pkg/plugins/manager/loader/assetpath/assetpath.go @@ -0,0 +1,70 @@ +package assetpath + +import ( + "fmt" + "net/url" + "path" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" +) + +// Service provides methods for constructing asset paths for plugins. +// It supports core plugins, external plugins stored on the local filesystem, and external plugins stored +// on the plugins CDN, and it will switch to the correct implementation depending on the plugin and the config. +type Service struct { + cdn *pluginscdn.Service +} + +func ProvideService(cdn *pluginscdn.Service) *Service { + return &Service{cdn: cdn} +} + +// Base returns the base path for the specified plugin. +func (s *Service) Base(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) { + if class == plugins.Core { + return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir)), nil + } + if s.cdn.PluginSupported(pluginJSON.ID) { + return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "") + } + return path.Join("public/plugins", pluginJSON.ID), nil +} + +// Module returns the module.js path for the specified plugin. +func (s *Service) Module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) { + if class == plugins.Core { + return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module"), nil + } + if s.cdn.PluginSupported(pluginJSON.ID) { + return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "module") + } + return path.Join("plugins", pluginJSON.ID, "module"), nil +} + +// RelativeURL returns the relative URL for an arbitrary plugin asset. +// If pathStr is an empty string, defaultStr is returned. +func (s *Service) RelativeURL(p *plugins.Plugin, pathStr, defaultStr string) (string, error) { + if pathStr == "" { + return defaultStr, nil + } + if s.cdn.PluginSupported(p.ID) { + // CDN + return s.cdn.NewCDNURLConstructor(p.ID, p.Info.Version).StringPath(pathStr) + } + // Local + u, err := url.Parse(pathStr) + if err != nil { + return "", fmt.Errorf("url parse: %w", err) + } + if u.IsAbs() { + return pathStr, nil + } + // is set as default or has already been prefixed with base path + if pathStr == defaultStr || strings.HasPrefix(pathStr, p.BaseURL) { + return pathStr, nil + } + return path.Join(p.BaseURL, pathStr), nil +} diff --git a/pkg/plugins/manager/loader/assetpath/assetpath_test.go b/pkg/plugins/manager/loader/assetpath/assetpath_test.go new file mode 100644 index 00000000000..1551aa57146 --- /dev/null +++ b/pkg/plugins/manager/loader/assetpath/assetpath_test.go @@ -0,0 +1,88 @@ +package assetpath + +import ( + "testing" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/stretchr/testify/require" +) + +func extPath(pluginID string) string { + return "/grafana/data/plugins/" + pluginID +} + +func TestService(t *testing.T) { + svc := ProvideService(pluginscdn.ProvideService(&config.Cfg{ + PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}", + PluginSettings: map[string]map[string]string{ + "one": {"cdn": "true"}, + "two": {}, + }, + })) + + const tableOldPath = "/grafana/public/app/plugins/panel/table-old" + jsonData := map[string]plugins.JSONData{ + "table-old": {ID: "table-old", Info: plugins.Info{Version: "1.0.0"}}, + + "one": {ID: "one", Info: plugins.Info{Version: "1.0.0"}}, + "two": {ID: "two", Info: plugins.Info{Version: "2.0.0"}}, + } + + t.Run("Base", func(t *testing.T) { + base, err := svc.Base(jsonData["one"], plugins.External, extPath("one")) + require.NoError(t, err) + require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one", base) + + base, err = svc.Base(jsonData["two"], plugins.External, extPath("two")) + require.NoError(t, err) + require.Equal(t, "public/plugins/two", base) + + base, err = svc.Base(jsonData["table-old"], plugins.Core, tableOldPath) + require.NoError(t, err) + require.Equal(t, "public/app/plugins/table-old", base) + }) + + t.Run("Module", func(t *testing.T) { + module, err := svc.Module(jsonData["one"], plugins.External, extPath("one")) + require.NoError(t, err) + require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one/module", module) + + module, err = svc.Module(jsonData["two"], plugins.External, extPath("two")) + require.NoError(t, err) + require.Equal(t, "plugins/two/module", module) + + module, err = svc.Module(jsonData["table-old"], plugins.Core, tableOldPath) + require.NoError(t, err) + require.Equal(t, "app/plugins/table-old/module", module) + }) + + t.Run("RelativeURL", func(t *testing.T) { + pluginsMap := map[string]*plugins.Plugin{ + "one": { + JSONData: plugins.JSONData{ID: "one", Info: plugins.Info{Version: "1.0.0"}}, + BaseURL: "plugin-cdn/one/1.0.0/public/pluginsMap/one", + }, + "two": { + JSONData: plugins.JSONData{ID: "two", Info: plugins.Info{Version: "2.0.0"}}, + BaseURL: "public/pluginsMap/two", + }, + } + u, err := svc.RelativeURL(pluginsMap["one"], "", "default") + require.NoError(t, err) + require.Equal(t, "default", u) + + u, err = svc.RelativeURL(pluginsMap["one"], "path/to/file.txt", "default") + require.NoError(t, err) + require.Equal(t, "https://cdn.example.com/one/1.0.0/public/plugins/one/path/to/file.txt", u) + + u, err = svc.RelativeURL(pluginsMap["two"], "path/to/file.txt", "default") + require.NoError(t, err) + require.Equal(t, "public/pluginsMap/two/path/to/file.txt", u) + + u, err = svc.RelativeURL(pluginsMap["two"], "default", "default") + require.NoError(t, err) + require.Equal(t, "default", u) + }) +} diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index 9c2abac8d8b..1d7d99bcbea 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "os" "path" "path/filepath" @@ -19,11 +18,13 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/logger" + "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" "github.com/grafana/grafana/pkg/plugins/manager/loader/initializer" "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/storage" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util" @@ -44,21 +45,25 @@ type Loader struct { pluginInitializer initializer.Initializer signatureValidator signature.Validator pluginStorage storage.Manager + pluginsCDN *pluginscdn.Service + assetPath *assetpath.Service log log.Logger + cfg *config.Cfg errs map[string]*plugins.SignatureError } func ProvideService(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer, pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider, - roleRegistry plugins.RoleRegistry) *Loader { + roleRegistry plugins.RoleRegistry, pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader { return New(cfg, license, authorizer, pluginRegistry, backendProvider, process.NewManager(pluginRegistry), - storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry) + storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry, pluginsCDNService, assetPath) } func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer, pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider, - processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry) *Loader { + processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry, + pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader { return &Loader{ pluginFinder: finder.New(), pluginRegistry: pluginRegistry, @@ -69,6 +74,9 @@ func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLo errs: make(map[string]*plugins.SignatureError), log: log.New("plugin.loader"), roleRegistry: roleRegistry, + cfg: cfg, + pluginsCDN: pluginsCDNService, + assetPath: assetPath, } } @@ -81,6 +89,36 @@ func (l *Loader) Load(ctx context.Context, class plugins.Class, paths []string) return l.loadPlugins(ctx, class, pluginJSONPaths) } +func (l *Loader) createPluginsForLoading(class plugins.Class, foundPlugins foundPlugins) map[string]*plugins.Plugin { + loadedPlugins := make(map[string]*plugins.Plugin) + for pluginDir, pluginJSON := range foundPlugins { + plugin, err := l.createPluginBase(pluginJSON, class, pluginDir) + if err != nil { + l.log.Warn("Could not create plugin base", "pluginID", pluginJSON.ID, "err", err) + continue + } + + // calculate initial signature state + var sig plugins.Signature + if l.pluginsCDN.PluginSupported(plugin.ID) { + // CDN plugins have no signature checks for now. + sig = plugins.Signature{Status: plugins.SignatureValid} + } else { + sig, err = signature.Calculate(l.log, plugin) + if err != nil { + l.log.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err) + continue + } + } + plugin.Signature = sig.Status + plugin.SignatureType = sig.Type + plugin.SignatureOrg = sig.SigningOrg + + loadedPlugins[plugin.PluginDir] = plugin + } + return loadedPlugins +} + func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSONPaths []string) ([]*plugins.Plugin, error) { var foundPlugins = foundPlugins{} @@ -113,22 +151,8 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO foundPlugins.stripDuplicates(registeredPlugins, l.log) - // calculate initial signature state - loadedPlugins := make(map[string]*plugins.Plugin) - for pluginDir, pluginJSON := range foundPlugins { - plugin := createPluginBase(pluginJSON, class, pluginDir) - - sig, err := signature.Calculate(l.log, plugin) - if err != nil { - l.log.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err) - continue - } - plugin.Signature = sig.Status - plugin.SignatureType = sig.Type - plugin.SignatureOrg = sig.SigningOrg - - loadedPlugins[plugin.PluginDir] = plugin - } + // create plugins structs and calculate signatures + loadedPlugins := l.createPluginsForLoading(class, foundPlugins) // wire up plugin dependencies for _, plugin := range loadedPlugins { @@ -165,12 +189,13 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO // clear plugin error if a pre-existing error has since been resolved delete(l.errs, plugin.ID) - // verify module.js exists for SystemJS to load + // verify module.js exists for SystemJS to load. + // CDN plugins can be loaded with plugin.json only, so do not warn for those. if !plugin.IsRenderer() && !plugin.IsCorePlugin() { module := filepath.Join(plugin.PluginDir, "module.js") if exists, err := fs.Exists(module); err != nil { return nil, err - } else if !exists { + } else if !exists && !l.pluginsCDN.PluginSupported(plugin.ID) { l.log.Warn("Plugin missing module.js", "pluginID", plugin.ID, "warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.", @@ -312,28 +337,47 @@ func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) return plugin, nil } -func createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) *plugins.Plugin { +func (l *Loader) createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (*plugins.Plugin, error) { + baseURL, err := l.assetPath.Base(pluginJSON, class, pluginDir) + if err != nil { + return nil, fmt.Errorf("base url: %w", err) + } + moduleURL, err := l.assetPath.Module(pluginJSON, class, pluginDir) + if err != nil { + return nil, fmt.Errorf("module url: %w", err) + } plugin := &plugins.Plugin{ JSONData: pluginJSON, PluginDir: pluginDir, - BaseURL: baseURL(pluginJSON, class, pluginDir), - Module: module(pluginJSON, class, pluginDir), + BaseURL: baseURL, + Module: moduleURL, Class: class, } plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID))) - setImages(plugin) + if err := l.setImages(plugin); err != nil { + return nil, err + } - return plugin + return plugin, nil } -func setImages(p *plugins.Plugin) { - p.Info.Logos.Small = pluginLogoURL(p.Type, p.Info.Logos.Small, p.BaseURL) - p.Info.Logos.Large = pluginLogoURL(p.Type, p.Info.Logos.Large, p.BaseURL) - - for i := 0; i < len(p.Info.Screenshots); i++ { - p.Info.Screenshots[i].Path = evalRelativePluginURLPath(p.Info.Screenshots[i].Path, p.BaseURL, p.Type) +func (l *Loader) setImages(p *plugins.Plugin) error { + var err error + for _, dst := range []*string{&p.Info.Logos.Small, &p.Info.Logos.Large} { + *dst, err = l.assetPath.RelativeURL(p, *dst, defaultLogoPath(p.Type)) + if err != nil { + return fmt.Errorf("logo: %w", err) + } } + for i := 0; i < len(p.Info.Screenshots); i++ { + screenshot := &p.Info.Screenshots[i] + screenshot.Path, err = l.assetPath.RelativeURL(p, screenshot.Path, "") + if err != nil { + return fmt.Errorf("screenshot %d relative url: %w", i, err) + } + } + return nil } func setDefaultNavURL(p *plugins.Plugin) { @@ -377,36 +421,10 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) { } } -func pluginLogoURL(pluginType plugins.Type, path, baseURL string) string { - if path == "" { - return defaultLogoPath(pluginType) - } - - return evalRelativePluginURLPath(path, baseURL, pluginType) -} - func defaultLogoPath(pluginType plugins.Type) string { return "public/img/icn-" + string(pluginType) + ".svg" } -func evalRelativePluginURLPath(pathStr, baseURL string, pluginType plugins.Type) string { - if pathStr == "" { - return "" - } - - u, _ := url.Parse(pathStr) - if u.IsAbs() { - return pathStr - } - - // is set as default or has already been prefixed with base path - if pathStr == defaultLogoPath(pluginType) || strings.HasPrefix(pathStr, baseURL) { - return pathStr - } - - return path.Join(baseURL, pathStr) -} - func (l *Loader) PluginErrors() []*plugins.Error { errs := make([]*plugins.Error, 0) for _, err := range l.errs { @@ -419,20 +437,6 @@ func (l *Loader) PluginErrors() []*plugins.Error { return errs } -func baseURL(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string { - if class == plugins.Core { - return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir)) - } - return path.Join("public/plugins", pluginJSON.ID) -} - -func module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string { - if class == plugins.Core { - return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module") - } - return path.Join("plugins", pluginJSON.ID, "module") -} - func validatePluginJSON(data plugins.JSONData) error { if data.ID == "" || !data.Type.IsValid() { return ErrInvalidPluginJSON diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 4c0ccbbcd6c..190ae69a90e 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -7,6 +7,9 @@ import ( "sort" "testing" + "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" @@ -404,6 +407,61 @@ func TestLoader_Load(t *testing.T) { }, }, }, + { + name: "Load CDN plugin", + class: plugins.External, + cfg: &config.Cfg{ + PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}", + PluginSettings: setting.PluginSettings{ + "grafana-worldmap-panel": {"cdn": "true"}, + }, + }, + pluginPaths: []string{"../testdata/cdn"}, + want: []*plugins.Plugin{ + { + JSONData: plugins.JSONData{ + ID: "grafana-worldmap-panel", + Type: "panel", + Name: "Worldmap Panel", + Info: plugins.Info{ + Version: "0.3.3", + Links: []plugins.InfoLink{ + {Name: "Project site", URL: "https://github.com/grafana/worldmap-panel"}, + {Name: "MIT License", URL: "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"}, + }, + Logos: plugins.Logos{ + // Path substitution + Small: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg", + Large: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg", + }, + Screenshots: []plugins.Screenshots{ + { + Name: "World", + Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-world.png", + }, + { + Name: "USA", + Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-usa.png", + }, + { + Name: "Light Theme", + Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-light-theme.png", + }, + }, + }, + Dependencies: plugins.Dependencies{ + GrafanaVersion: "3.x.x", + Plugins: []plugins.Dependency{}, + }, + }, + PluginDir: filepath.Join(parentDir, "testdata/cdn/plugin"), + Class: plugins.External, + Signature: plugins.SignatureValid, + BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", + Module: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module", + }, + }, + }, } for _, tt := range tests { reg := fakes.NewFakePluginRegistry() @@ -1320,9 +1378,10 @@ func Test_setPathsBasedOnApp(t *testing.T) { } func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader { + cdn := pluginscdn.ProvideService(cfg) l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(), fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakePluginStorage(), - fakes.NewFakeRoleRegistry()) + fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn)) for _, cb := range cbs { cb(l) diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index fe1391c862d..5fcc288e917 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -8,6 +8,9 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" @@ -110,10 +113,12 @@ func TestIntegrationPluginManager(t *testing.T) { pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg) reg := registry.ProvideService() + cdn := pluginscdn.ProvideService(pCfg) lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg}) l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg), - reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry()) + reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(), + cdn, assetpath.ProvideService(cdn)) ps, err := store.ProvideService(cfg, pCfg, reg, l) require.NoError(t, err) diff --git a/pkg/plugins/manager/signature/signature.go b/pkg/plugins/manager/signature/signature.go index 2e7262d7569..3954a4850d6 100644 --- a/pkg/plugins/manager/signature/signature.go +++ b/pkg/plugins/manager/signature/signature.go @@ -18,14 +18,14 @@ func NewValidator(authorizer plugins.PluginLoaderAuthorizer) Validator { } func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError { - if plugin.Signature == plugins.SignatureValid { + if plugin.Signature.IsValid() { s.log.Debug("Plugin has valid signature", "id", plugin.ID) return nil } // If a plugin is nested within another, create links to each other to inherit signature details if plugin.Parent != nil { - if plugin.IsCorePlugin() || plugin.Signature == plugins.SignatureInternal { + if plugin.IsCorePlugin() || plugin.Signature.IsInternal() { s.log.Debug("Not setting descendant plugin's signature to that of root since it's core or internal", "plugin", plugin.ID, "signature", plugin.Signature, "isCore", plugin.IsCorePlugin()) } else { @@ -34,7 +34,7 @@ func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError { plugin.Signature = plugin.Parent.Signature plugin.SignatureType = plugin.Parent.SignatureType plugin.SignatureOrg = plugin.Parent.SignatureOrg - if plugin.Signature == plugins.SignatureValid { + if plugin.Signature.IsValid() { s.log.Debug("Plugin has valid signature (inherited from root)", "id", plugin.ID) return nil } diff --git a/pkg/plugins/manager/testdata/cdn/plugin/plugin.json b/pkg/plugins/manager/testdata/cdn/plugin/plugin.json new file mode 100644 index 00000000000..e0c88b874da --- /dev/null +++ b/pkg/plugins/manager/testdata/cdn/plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "type": "panel", + "name": "Worldmap Panel", + "id": "grafana-worldmap-panel", + "info": { + "logos": { + "small": "images/worldmap_logo.svg", + "large": "images/worldmap_logo.svg" + }, + "links": [ + { + "name": "Project site", + "url": "https://github.com/grafana/worldmap-panel" + }, + { + "name": "MIT License", + "url": "https://github.com/grafana/worldmap-panel/blob/master/LICENSE" + } + ], + "screenshots": [ + { + "name": "World", + "path": "images/worldmap-world.png" + }, + { + "name": "USA", + "path": "images/worldmap-usa.png" + }, + { + "name": "Light Theme", + "path": "images/worldmap-light-theme.png" + } + ], + "version": "0.3.3" + }, + "dependencies": { + "grafanaVersion": "3.x.x", + "plugins": [] + } +} diff --git a/pkg/plugins/pfs/corelist/corelist_load_gen.go b/pkg/plugins/pfs/corelist/corelist_load_gen.go index 77b8a15304c..4ba87ce393d 100644 --- a/pkg/plugins/pfs/corelist/corelist_load_gen.go +++ b/pkg/plugins/pfs/corelist/corelist_load_gen.go @@ -71,6 +71,8 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin { parsePluginOrPanic("public/app/plugins/panel/nodeGraph", "nodeGraph", rt), parsePluginOrPanic("public/app/plugins/panel/piechart", "piechart", rt), parsePluginOrPanic("public/app/plugins/panel/stat", "stat", rt), + parsePluginOrPanic("public/app/plugins/panel/state-timeline", "state_timeline", rt), + parsePluginOrPanic("public/app/plugins/panel/status-history", "status_history", rt), parsePluginOrPanic("public/app/plugins/panel/table-old", "table_old", rt), parsePluginOrPanic("public/app/plugins/panel/text", "text", rt), parsePluginOrPanic("public/app/plugins/panel/traces", "traces", rt), diff --git a/pkg/plugins/pfs/pfs_test.go b/pkg/plugins/pfs/pfs_test.go index 7f1dd59ecb2..bef758a5924 100644 --- a/pkg/plugins/pfs/pfs_test.go +++ b/pkg/plugins/pfs/pfs_test.go @@ -127,6 +127,10 @@ func TestParsePluginTestdata(t *testing.T) { "disallowed-cue-import": { err: ErrDisallowedCUEImport, }, + "cdn": { + rootid: "grafana-worldmap-panel", + subpath: "plugin", + }, } staticRootPath, err := filepath.Abs("../manager/testdata") diff --git a/pkg/plugins/plugindef/gen.go b/pkg/plugins/plugindef/gen.go index d3a7333282b..c72795da2e9 100644 --- a/pkg/plugins/plugindef/gen.go +++ b/pkg/plugins/plugindef/gen.go @@ -14,7 +14,6 @@ import ( "cuelang.org/go/cue/cuecontext" "github.com/dave/dst" - "github.com/dave/dst/dstutil" "github.com/grafana/codejen" "github.com/grafana/grafana/pkg/codegen" "github.com/grafana/grafana/pkg/cuectx" @@ -62,9 +61,7 @@ func (j *jennytypego) JennyName() string { } func (j *jennytypego) Generate(lin thema.Lineage) (*codejen.File, error) { - f, err := codegen.GoTypesJenny{ApplyFuncs: []dstutil.ApplyFunc{ - codegen.PrefixReplacer("Plugindef", "PluginDef"), - }}.Generate(codegen.SchemaForGen{ + f, err := codegen.GoTypesJenny{}.Generate(codegen.SchemaForGen{ Name: "PluginDef", Schema: lin.Latest(), IsGroup: false, diff --git a/pkg/plugins/plugindef/plugindef_types_gen.go b/pkg/plugins/plugindef/plugindef_types_gen.go index a38336c20a4..7b2f793eef3 100644 --- a/pkg/plugins/plugindef/plugindef_types_gen.go +++ b/pkg/plugins/plugindef/plugindef_types_gen.go @@ -55,19 +55,7 @@ const ( IncludeTypeSecretsmanager IncludeType = "secretsmanager" ) -// Defines values for ReleaseState. -const ( - ReleaseStateAlpha ReleaseState = "alpha" - - ReleaseStateBeta ReleaseState = "beta" - - ReleaseStateDeprecated ReleaseState = "deprecated" - - ReleaseStateStable ReleaseState = "stable" -) - // Defines values for Category. -// Defines values for PlugindefCategory. const ( CategoryCloud Category = "cloud" @@ -87,7 +75,6 @@ const ( ) // Defines values for Type. -// Defines values for PlugindefType. const ( TypeApp Type = "app" @@ -100,6 +87,17 @@ const ( TypeSecretsmanager Type = "secretsmanager" ) +// Defines values for ReleaseState. +const ( + ReleaseStateAlpha ReleaseState = "alpha" + + ReleaseStateBeta ReleaseState = "beta" + + ReleaseStateDeprecated ReleaseState = "deprecated" + + ReleaseStateStable ReleaseState = "stable" +) + // BasicRole is a Grafana basic role, which can be 'Viewer', 'Editor', 'Admin' or 'Grafana Admin'. // With RBAC, the Admin basic role inherits its default permissions from the Editor basic role which // in turn inherits them from the Viewer basic role. @@ -274,94 +272,7 @@ type Permission struct { Scope *string `json:"scope,omitempty"` } -// ReleaseState indicates release maturity state of a plugin. -type ReleaseState string - -// Role describes an RBAC role which allows grouping multiple related permissions on the plugin, -// each of which has an action and an optional scope. -// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. -type Role struct { - Description string `json:"description"` - Name string `json:"name"` - Permissions []Permission `json:"permissions"` -} - -// RoleRegistration describes an RBAC role and its assignments to basic roles. -// It organizes related RBAC permissions on the plugin into a role and defines which basic roles -// will get them by default. -// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin -// which will be granted to Admins by default. -type RoleRegistration struct { - // Default assignment of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin) - // The Admin basic role inherits its default permissions from the Editor basic role which in turn - // inherits them from the Viewer basic role. - Grants []BasicRole `json:"grants"` - - // Role describes an RBAC role which allows grouping multiple related permissions on the plugin, - // each of which has an action and an optional scope. - // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. - Role Role `json:"role"` -} - -// A proxy route used in datasource plugins for plugin authentication -// and adding headers to HTTP requests made by the plugin. -// For more information, refer to [Authentication for data source -// plugins](https://grafana.com/docs/grafana/latest/developers/plugins/authentication/). -type Route struct { - // For data source plugins. Route headers set the body content and - // length to the proxied request. - Body *map[string]interface{} `json:"body,omitempty"` - - // For data source plugins. Route headers adds HTTP headers to the - // proxied request. - Headers *[]Header `json:"headers,omitempty"` - - // TODO docs - // TODO should this really be separate from TokenAuth? - JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth,omitempty"` - - // For data source plugins. Route method matches the HTTP verb - // like GET or POST. Multiple methods can be provided as a - // comma-separated list. - Method *string `json:"method,omitempty"` - - // For data source plugins. The route path that is replaced by the - // route URL field when proxying the call. - Path *string `json:"path,omitempty"` - ReqRole *string `json:"reqRole,omitempty"` - ReqSignedIn *bool `json:"reqSignedIn,omitempty"` - - // TODO docs - TokenAuth *TokenAuth `json:"tokenAuth,omitempty"` - - // For data source plugins. Route URL is where the request is - // proxied to. - Url *string `json:"url,omitempty"` - UrlParams *[]URLParam `json:"urlParams,omitempty"` -} - -// TODO docs -type TokenAuth struct { - // Parameters for the token authentication request. - Params map[string]string `json:"params"` - - // The list of scopes that your application should be granted - // access to. - Scopes *[]string `json:"scopes,omitempty"` - - // URL to fetch the authentication token. - Url *string `json:"url,omitempty"` -} - -// URLParam describes query string parameters for -// a url in a plugin route -type URLParam struct { - Content string `json:"content"` - Name string `json:"name"` -} - -// Plugindef defines model for plugindef. -// PluginDef defines model for plugindef. +// PluginDef defines model for PluginDef. type PluginDef struct { // For data source plugins, if the plugin supports alerting. Alerting *bool `json:"alerting,omitempty"` @@ -496,3 +407,89 @@ type Category string // type indicates which type of Grafana plugin this is, of the defined // set of Grafana plugin types. type Type string + +// ReleaseState indicates release maturity state of a plugin. +type ReleaseState string + +// Role describes an RBAC role which allows grouping multiple related permissions on the plugin, +// each of which has an action and an optional scope. +// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. +type Role struct { + Description string `json:"description"` + Name string `json:"name"` + Permissions []Permission `json:"permissions"` +} + +// RoleRegistration describes an RBAC role and its assignments to basic roles. +// It organizes related RBAC permissions on the plugin into a role and defines which basic roles +// will get them by default. +// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin +// which will be granted to Admins by default. +type RoleRegistration struct { + // Default assignment of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin) + // The Admin basic role inherits its default permissions from the Editor basic role which in turn + // inherits them from the Viewer basic role. + Grants []BasicRole `json:"grants"` + + // Role describes an RBAC role which allows grouping multiple related permissions on the plugin, + // each of which has an action and an optional scope. + // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. + Role Role `json:"role"` +} + +// A proxy route used in datasource plugins for plugin authentication +// and adding headers to HTTP requests made by the plugin. +// For more information, refer to [Authentication for data source +// plugins](https://grafana.com/docs/grafana/latest/developers/plugins/authentication/). +type Route struct { + // For data source plugins. Route headers set the body content and + // length to the proxied request. + Body *map[string]interface{} `json:"body,omitempty"` + + // For data source plugins. Route headers adds HTTP headers to the + // proxied request. + Headers *[]Header `json:"headers,omitempty"` + + // TODO docs + // TODO should this really be separate from TokenAuth? + JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth,omitempty"` + + // For data source plugins. Route method matches the HTTP verb + // like GET or POST. Multiple methods can be provided as a + // comma-separated list. + Method *string `json:"method,omitempty"` + + // For data source plugins. The route path that is replaced by the + // route URL field when proxying the call. + Path *string `json:"path,omitempty"` + ReqRole *string `json:"reqRole,omitempty"` + ReqSignedIn *bool `json:"reqSignedIn,omitempty"` + + // TODO docs + TokenAuth *TokenAuth `json:"tokenAuth,omitempty"` + + // For data source plugins. Route URL is where the request is + // proxied to. + Url *string `json:"url,omitempty"` + UrlParams *[]URLParam `json:"urlParams,omitempty"` +} + +// TODO docs +type TokenAuth struct { + // Parameters for the token authentication request. + Params map[string]string `json:"params"` + + // The list of scopes that your application should be granted + // access to. + Scopes *[]string `json:"scopes,omitempty"` + + // URL to fetch the authentication token. + Url *string `json:"url,omitempty"` +} + +// URLParam describes query string parameters for +// a url in a plugin route +type URLParam struct { + Content string `json:"content"` + Name string `json:"name"` +} diff --git a/pkg/plugins/pluginscdn/pluginscdn.go b/pkg/plugins/pluginscdn/pluginscdn.go new file mode 100644 index 00000000000..3539d9ac765 --- /dev/null +++ b/pkg/plugins/pluginscdn/pluginscdn.go @@ -0,0 +1,80 @@ +package pluginscdn + +import ( + "errors" + "fmt" + "net/url" + "path" + + "github.com/grafana/grafana/pkg/plugins/config" +) + +const ( + // systemJSCDNKeyword is the path prefix used by system.js to identify the plugins CDN. + systemJSCDNKeyword = "plugin-cdn" +) + +var ErrPluginNotCDN = errors.New("plugin is not a cdn plugin") + +// Service provides methods for the plugins CDN. +type Service struct { + cfg *config.Cfg +} + +func ProvideService(cfg *config.Cfg) *Service { + return &Service{cfg: cfg} +} + +// NewCDNURLConstructor returns a new URLConstructor for the provided plugin id and version. +// The CDN should be enabled for the plugin, otherwise the returned URLConstructor will have +// and invalid base url. +func (s *Service) NewCDNURLConstructor(pluginID, pluginVersion string) URLConstructor { + return URLConstructor{ + cdnURLTemplate: s.cfg.PluginsCDNURLTemplate, + pluginID: pluginID, + pluginVersion: pluginVersion, + } +} + +// IsEnabled returns true if the plugins cdn is enabled. +func (s *Service) IsEnabled() bool { + return s.cfg.PluginsCDNURLTemplate != "" +} + +// PluginSupported returns true if the CDN is enabled in the config and if the specified plugin ID has CDN enabled. +func (s *Service) PluginSupported(pluginID string) bool { + return s.IsEnabled() && s.cfg.PluginSettings[pluginID]["cdn"] != "" +} + +// BaseURL returns the absolute base URL of the plugins CDN. +// If the plugins CDN is disabled, it returns an empty string. +func (s *Service) BaseURL() (string, error) { + if !s.IsEnabled() { + return "", nil + } + u, err := url.Parse(s.cfg.PluginsCDNURLTemplate) + if err != nil { + return "", fmt.Errorf("url parse: %w", err) + } + return u.Scheme + "://" + u.Host, nil +} + +// SystemJSAssetPath returns a system-js path for the specified asset on the plugins CDN. +// It replaces the base path of the CDN with systemJSCDNKeyword. +// If assetPath is an empty string, the base path for the plugin is returned. +func (s *Service) SystemJSAssetPath(pluginID, pluginVersion, assetPath string) (string, error) { + u, err := s.NewCDNURLConstructor(pluginID, pluginVersion).Path(assetPath) + if err != nil { + return "", err + } + return path.Join(systemJSCDNKeyword, u.Path), nil +} + +// AssetURL returns the URL of a CDN asset for a CDN plugin. If the specified plugin is not a CDN plugin, +// it returns ErrPluginNotCDN. +func (s *Service) AssetURL(pluginID, pluginVersion, assetPath string) (string, error) { + if !s.PluginSupported(pluginID) { + return "", ErrPluginNotCDN + } + return s.NewCDNURLConstructor(pluginID, pluginVersion).StringPath(assetPath) +} diff --git a/pkg/plugins/pluginscdn/pluginscdn_test.go b/pkg/plugins/pluginscdn/pluginscdn_test.go new file mode 100644 index 00000000000..b473aeb4b54 --- /dev/null +++ b/pkg/plugins/pluginscdn/pluginscdn_test.go @@ -0,0 +1,49 @@ +package pluginscdn + +import ( + "testing" + + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/stretchr/testify/require" +) + +func TestService(t *testing.T) { + svc := ProvideService(&config.Cfg{ + PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}", + PluginSettings: map[string]map[string]string{ + "one": {"cdn": "true"}, + "two": {}, + }, + }) + + t.Run("IsCDNPlugin", func(t *testing.T) { + require.True(t, svc.PluginSupported("one")) + require.False(t, svc.PluginSupported("two")) + require.False(t, svc.PluginSupported("unknown")) + }) + + t.Run("CDNBaseURL", func(t *testing.T) { + for _, c := range []struct { + name string + cfgURL string + expBaseURL string + }{ + { + name: "valid", + cfgURL: "https://grafana-assets.grafana.net/plugin-cdn-test/plugin-cdn/{id}/{version}/public/plugins/{id}/{assetPath}", + expBaseURL: "https://grafana-assets.grafana.net", + }, + { + name: "empty", + cfgURL: "", + expBaseURL: "", + }, + } { + t.Run(c.name, func(t *testing.T) { + u, err := ProvideService(&config.Cfg{PluginsCDNURLTemplate: c.cfgURL}).BaseURL() + require.NoError(t, err) + require.Equal(t, c.expBaseURL, u) + }) + } + }) +} diff --git a/pkg/plugins/pluginscdn/url_constructor.go b/pkg/plugins/pluginscdn/url_constructor.go new file mode 100644 index 00000000000..c4ab35ca668 --- /dev/null +++ b/pkg/plugins/pluginscdn/url_constructor.go @@ -0,0 +1,61 @@ +package pluginscdn + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +// URLConstructor is a struct that can build CDN URLs for plugins on a remote CDN. +type URLConstructor struct { + // cdnURLTemplate is absolute base url of the CDN. This string will be formatted + // according to the rules specified in the Path method. + cdnURLTemplate string + + // pluginID is the ID of the plugin. + pluginID string + + // pluginVersion is the version of the plugin. + pluginVersion string +} + +// Path returns a new *url.URL that points to an asset file for the CDN, plugin and plugin version +// specified by the current URLConstructor. +// +// c.cdnURLTemplate is used to build the string, the following substitutions are performed in it: +// +// - {id} -> plugin id +// +// - {version} -> plugin version +// +// - {assetPath} -> assetPath +// +// The asset Path is sanitized via path.Clean (double slashes are removed, "../" is resolved, etc). +// +// The returned URL will be for a file, so it won't have a trailing slash. +func (c URLConstructor) Path(assetPath string) (*url.URL, error) { + u, err := url.Parse( + strings.TrimRight( + strings.NewReplacer( + "{id}", c.pluginID, + "{version}", c.pluginVersion, + "{assetPath}", strings.Trim(path.Clean("/"+assetPath+"/"), "/"), + ).Replace(c.cdnURLTemplate), + "/", + ), + ) + if err != nil { + return nil, fmt.Errorf("url parse: %w", err) + } + return u, nil +} + +// StringPath is like Path, but it returns the absolute URL as a string rather than *url.URL. +func (c URLConstructor) StringPath(assetPath string) (string, error) { + u, err := c.Path(assetPath) + if err != nil { + return "", err + } + return u.String(), nil +} diff --git a/pkg/plugins/pluginscdn/url_constructor_test.go b/pkg/plugins/pluginscdn/url_constructor_test.go new file mode 100644 index 00000000000..aae290c432d --- /dev/null +++ b/pkg/plugins/pluginscdn/url_constructor_test.go @@ -0,0 +1,34 @@ +package pluginscdn + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestURLConstructor_StringURLFor(t *testing.T) { + uc := URLConstructor{ + cdnURLTemplate: "https://the.cdn/{id}/{version}/{assetPath}", + pluginID: "the-plugin", + pluginVersion: "0.1", + } + type tc struct { + name string + path string + exp string + } + for _, c := range []tc{ + {"simple", "file.txt", "https://the.cdn/the-plugin/0.1/file.txt"}, + {"multiple", "some/path/to/file.txt", "https://the.cdn/the-plugin/0.1/some/path/to/file.txt"}, + {"path traversal", "some/../to/file.txt", "https://the.cdn/the-plugin/0.1/to/file.txt"}, + {"above root", "../../../../../file.txt", "https://the.cdn/the-plugin/0.1/file.txt"}, + {"multiple slashes", "some/////file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"}, + {"dots", "some/././././file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"}, + } { + t.Run(c.name, func(t *testing.T) { + u, err := uc.StringPath(c.path) + require.NoError(t, err) + require.Equal(t, c.exp, u) + }) + } +} diff --git a/pkg/plugins/storage/fs.go b/pkg/plugins/storage/fs.go index 302305a1196..e1bf0b99163 100644 --- a/pkg/plugins/storage/fs.go +++ b/pkg/plugins/storage/fs.go @@ -220,7 +220,7 @@ func isSymlinkRelativeTo(basePath string, symlinkDestPath string, symlinkOrigPat func extractFile(file *zip.File, filePath string) (err error) { fileMode := file.Mode() // This is entry point for backend plugins so we want to make them executable - if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_darwin_amd64") { + if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_linux_arm") || strings.HasSuffix(filePath, "_linux_arm64") || strings.HasSuffix(filePath, "_darwin_amd64") || strings.HasSuffix(filePath, "_darwin_arm64") || strings.HasSuffix(filePath, "_windows_amd64.exe") { fileMode = os.FileMode(0755) } diff --git a/pkg/plugins/storage/fs_test.go b/pkg/plugins/storage/fs_test.go index 044bb0ede12..e55e69842e2 100644 --- a/pkg/plugins/storage/fs_test.go +++ b/pkg/plugins/storage/fs_test.go @@ -157,7 +157,7 @@ func TestExtractFiles(t *testing.T) { // File in zip has permission 644 fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe") require.NoError(t, err) - require.Equal(t, "-rw-r--r--", fileInfo.Mode().String()) + require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) // File in zip has permission 755 fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary") diff --git a/pkg/registry/corekind/base_gen.go b/pkg/registry/corekind/base_gen.go index 2e9753ed1a6..8a180db1ca0 100644 --- a/pkg/registry/corekind/base_gen.go +++ b/pkg/registry/corekind/base_gen.go @@ -14,6 +14,9 @@ import ( "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/kinds/playlist" + "github.com/grafana/grafana/pkg/kinds/preferences" + "github.com/grafana/grafana/pkg/kinds/publicdashboard" + "github.com/grafana/grafana/pkg/kinds/serviceaccount" "github.com/grafana/grafana/pkg/kinds/team" "github.com/grafana/grafana/pkg/kindsys" "github.com/grafana/thema" @@ -30,16 +33,22 @@ import ( // Prefer All*() methods when performing operations generically across all kinds. // For example, a validation HTTP middleware for any kind-schematized object type. type Base struct { - all []kindsys.Core - dashboard *dashboard.Kind - playlist *playlist.Kind - team *team.Kind + all []kindsys.Core + dashboard *dashboard.Kind + playlist *playlist.Kind + preferences *preferences.Kind + publicdashboard *publicdashboard.Kind + serviceaccount *serviceaccount.Kind + team *team.Kind } // type guards var ( _ kindsys.Core = &dashboard.Kind{} _ kindsys.Core = &playlist.Kind{} + _ kindsys.Core = &preferences.Kind{} + _ kindsys.Core = &publicdashboard.Kind{} + _ kindsys.Core = &serviceaccount.Kind{} _ kindsys.Core = &team.Kind{} ) @@ -53,6 +62,21 @@ func (b *Base) Playlist() *playlist.Kind { return b.playlist } +// Preferences returns the [kindsys.Interface] implementation for the preferences kind. +func (b *Base) Preferences() *preferences.Kind { + return b.preferences +} + +// PublicDashboard returns the [kindsys.Interface] implementation for the publicdashboard kind. +func (b *Base) PublicDashboard() *publicdashboard.Kind { + return b.publicdashboard +} + +// ServiceAccount returns the [kindsys.Interface] implementation for the serviceaccount kind. +func (b *Base) ServiceAccount() *serviceaccount.Kind { + return b.serviceaccount +} + // Team returns the [kindsys.Interface] implementation for the team kind. func (b *Base) Team() *team.Kind { return b.team @@ -74,6 +98,24 @@ func doNewBase(rt *thema.Runtime) *Base { } reg.all = append(reg.all, reg.playlist) + reg.preferences, err = preferences.NewKind(rt) + if err != nil { + panic(fmt.Sprintf("error while initializing the preferences Kind: %s", err)) + } + reg.all = append(reg.all, reg.preferences) + + reg.publicdashboard, err = publicdashboard.NewKind(rt) + if err != nil { + panic(fmt.Sprintf("error while initializing the publicdashboard Kind: %s", err)) + } + reg.all = append(reg.all, reg.publicdashboard) + + reg.serviceaccount, err = serviceaccount.NewKind(rt) + if err != nil { + panic(fmt.Sprintf("error while initializing the serviceaccount Kind: %s", err)) + } + reg.all = append(reg.all, reg.serviceaccount) + reg.team, err = team.NewKind(rt) if err != nil { panic(fmt.Sprintf("error while initializing the team Kind: %s", err)) diff --git a/pkg/server/server.go b/pkg/server/server.go index 321cbeb8e10..c59a37e1c6f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -13,17 +13,13 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/loginattempt" "github.com/grafana/grafana/pkg/api" _ "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "golang.org/x/sync/errgroup" @@ -43,10 +39,9 @@ type Options struct { func New(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistry accesscontrol.RoleRegistry, provisioningService provisioning.ProvisioningService, backgroundServiceProvider registry.BackgroundServiceRegistry, usageStatsProvidersRegistry registry.UsageStatsProvidersRegistry, statsCollectorService *statscollector.Service, - userService user.Service, loginAttemptService loginattempt.Service, ) (*Server, error) { statsCollectorService.RegisterProviders(usageStatsProvidersRegistry.GetServices()) - s, err := newServer(opts, cfg, httpServer, roleRegistry, provisioningService, backgroundServiceProvider, userService, loginAttemptService) + s, err := newServer(opts, cfg, httpServer, roleRegistry, provisioningService, backgroundServiceProvider) if err != nil { return nil, err } @@ -59,7 +54,7 @@ func New(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistr } func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistry accesscontrol.RoleRegistry, - provisioningService provisioning.ProvisioningService, backgroundServiceProvider registry.BackgroundServiceRegistry, userService user.Service, loginAttemptService loginattempt.Service, + provisioningService provisioning.ProvisioningService, backgroundServiceProvider registry.BackgroundServiceRegistry, ) (*Server, error) { rootCtx, shutdownFn := context.WithCancel(context.Background()) childRoutines, childCtx := errgroup.WithContext(rootCtx) @@ -79,8 +74,6 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR commit: opts.Commit, buildBranch: opts.BuildBranch, backgroundServices: backgroundServiceProvider.GetServices(), - userService: userService, - loginAttemptService: loginAttemptService, } return s, nil @@ -107,8 +100,6 @@ type Server struct { HTTPServer *api.HTTPServer roleRegistry accesscontrol.RoleRegistry provisioningService provisioning.ProvisioningService - userService user.Service - loginAttemptService loginattempt.Service } // init initializes the server and its services. @@ -129,9 +120,6 @@ func (s *Server) init() error { return err } - login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login, s.loginAttemptService, s.userService) - social.ProvideService(s.cfg, s.HTTPServer.Features) - if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil { return err } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 324f772d5ef..be4aca1585e 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/server/backgroundsvcs" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -48,7 +47,7 @@ func (s *testService) IsDisabled() bool { func testServer(t *testing.T, services ...registry.BackgroundService) *Server { t.Helper() - s, err := newServer(Options{}, setting.NewCfg(), nil, &acimpl.Service{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...), usertest.NewUserServiceFake(), nil) + s, err := newServer(Options{}, setting.NewCfg(), nil, &acimpl.Service{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...)) require.NoError(t, err) // Required to skip configuration initialization that causes // DI errors in this test. diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 54595f3f44a..52898adfddf 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -116,6 +116,7 @@ import ( "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/shorturls/shorturlimpl" "github.com/grafana/grafana/pkg/services/sqlstore" + starApi "github.com/grafana/grafana/pkg/services/star/api" "github.com/grafana/grafana/pkg/services/star/starimpl" "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/services/store" @@ -286,12 +287,12 @@ var wireBasicSet = wire.NewSet( teamguardianManager.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, - dashboardservice.ProvideDashboardService, + dashboardservice.ProvideDashboardService, // DashboardServiceImpl dashboardstore.ProvideDashboardStore, folderimpl.ProvideService, - wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)), - wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)), - wire.Bind(new(dashboards.PluginService), new(*dashboardservice.DashboardServiceImpl)), + dashboardservice.ProvideSimpleDashboardService, + dashboardservice.ProvideDashboardProvisioningService, + dashboardservice.ProvideDashboardPluginService, wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)), wire.Bind(new(dashboards.FolderStore), new(*dashboardstore.DashboardStore)), dashboardimportservice.ProvideService, @@ -327,6 +328,7 @@ var wireBasicSet = wire.NewSet( publicdashboardsStore.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*publicdashboardsStore.PublicDashboardStoreImpl)), publicdashboardsApi.ProvideApi, + starApi.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, statsimpl.ProvideService, diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index ef2ca546a57..16ca6324ee0 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -100,8 +100,8 @@ type User struct { } // HasGlobalAccess checks user access with globally assigned permissions only -func HasGlobalAccess(ac AccessControl, service Service, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { - return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { +func HasGlobalAccess(ac AccessControl, service Service, c *contextmodel.ReqContext) func(fallback func(*contextmodel.ReqContext) bool, evaluator Evaluator) bool { + return func(fallback func(*contextmodel.ReqContext) bool, evaluator Evaluator) bool { if ac.IsDisabled() { return fallback(c) } @@ -131,8 +131,8 @@ func HasGlobalAccess(ac AccessControl, service Service, c *models.ReqContext) fu } } -func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { - return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { +func HasAccess(ac AccessControl, c *contextmodel.ReqContext) func(fallback func(*contextmodel.ReqContext) bool, evaluator Evaluator) bool { + return func(fallback func(*contextmodel.ReqContext) bool, evaluator Evaluator) bool { if ac.IsDisabled() { return fallback(c) } @@ -147,31 +147,31 @@ func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*model } } -var ReqSignedIn = func(c *models.ReqContext) bool { +var ReqSignedIn = func(c *contextmodel.ReqContext) bool { return c.IsSignedIn } -var ReqGrafanaAdmin = func(c *models.ReqContext) bool { +var ReqGrafanaAdmin = func(c *contextmodel.ReqContext) bool { return c.IsGrafanaAdmin } // ReqViewer returns true if the current user has org.RoleViewer. Note: this can be anonymous user as well -var ReqViewer = func(c *models.ReqContext) bool { +var ReqViewer = func(c *contextmodel.ReqContext) bool { return c.OrgRole.Includes(org.RoleViewer) } -var ReqOrgAdmin = func(c *models.ReqContext) bool { +var ReqOrgAdmin = func(c *contextmodel.ReqContext) bool { return c.OrgRole == org.RoleAdmin } -var ReqOrgAdminOrEditor = func(c *models.ReqContext) bool { +var ReqOrgAdminOrEditor = func(c *contextmodel.ReqContext) bool { return c.OrgRole == org.RoleAdmin || c.OrgRole == org.RoleEditor } // ReqHasRole generates a fallback to check whether the user has a role // Note that while ReqOrgAdmin returns false for a Grafana Admin / Viewer, ReqHasRole(org.RoleAdmin) will return true -func ReqHasRole(role org.RoleType) func(c *models.ReqContext) bool { - return func(c *models.ReqContext) bool { return c.HasRole(role) } +func ReqHasRole(role org.RoleType) func(c *contextmodel.ReqContext) bool { + return func(c *contextmodel.ReqContext) bool { return c.HasRole(role) } } func BuildPermissionsMap(permissions []Permission) map[string]bool { diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go index 3d864a3aa28..2a12199eda2 100644 --- a/pkg/services/accesscontrol/acimpl/service_bench_test.go +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -3,13 +3,13 @@ package acimpl import ( "context" "fmt" - "sync" "testing" "time" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -17,64 +17,6 @@ import ( "github.com/stretchr/testify/require" ) -const concurrency = 10 -const batchSize = 1000 - -type bounds struct { - start, end int -} - -// concurrentBatch spawns the requested amount of workers then ask them to run eachFn on chunks of the requested size -func concurrentBatch(workers, count, size int, eachFn func(start, end int) error) error { - var wg sync.WaitGroup - alldone := make(chan bool) // Indicates that all workers have finished working - chunk := make(chan bounds) // Gives the workers the bounds they should work with - ret := make(chan error) // Allow workers to notify in case of errors - defer close(ret) - - // Launch all workers - for x := 0; x < workers; x++ { - wg.Add(1) - go func() { - defer wg.Done() - for ck := range chunk { - if err := eachFn(ck.start, ck.end); err != nil { - ret <- err - return - } - } - }() - } - - go func() { - // Tell the workers the chunks they have to work on - for i := 0; i < count; { - end := i + size - if end > count { - end = count - } - - chunk <- bounds{start: i, end: end} - - i = end - } - close(chunk) - - // Wait for the workers - wg.Wait() - close(alldone) - }() - - // wait for an error or for all workers to be done - select { - case err := <-ret: - return err - case <-alldone: - break - } - return nil -} - // setupBenchEnv will create userCount users, userCount managed roles with resourceCount managed permission each // Example: setupBenchEnv(b, 2, 3): // - will create 2 users and assign them 2 managed roles @@ -102,7 +44,7 @@ func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.S require.NoError(b, err) // Populate users, roles and assignments - if errInsert := concurrentBatch(concurrency, usersCount, batchSize, func(start, end int) error { + if errInsert := actest.ConcurrentBatch(actest.Concurrency, usersCount, actest.BatchSize, func(start, end int) error { n := end - start users := make([]user.User, 0, n) orgUsers := make([]org.OrgUser, 0, n) @@ -157,13 +99,13 @@ func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.S }) return err }); errInsert != nil { - require.NoError(b, err, "could not insert users and roles") + require.NoError(b, errInsert, "could not insert users and roles") return nil, nil } // Populate permissions action2 := "resources:action2" - if errInsert := concurrentBatch(concurrency, resourceCount*usersCount, batchSize, func(start, end int) error { + if errInsert := actest.ConcurrentBatch(actest.Concurrency, resourceCount*usersCount, actest.BatchSize, func(start, end int) error { permissions := make([]accesscontrol.Permission, 0, end-start) for i := start; i < end; i++ { permissions = append(permissions, accesscontrol.Permission{ @@ -180,7 +122,7 @@ func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.S return err }) }); errInsert != nil { - require.NoError(b, err, "could not insert permissions") + require.NoError(b, errInsert, "could not insert permissions") return nil, nil } diff --git a/pkg/services/accesscontrol/actest/common.go b/pkg/services/accesscontrol/actest/common.go new file mode 100644 index 00000000000..5aa71efe08c --- /dev/null +++ b/pkg/services/accesscontrol/actest/common.go @@ -0,0 +1,61 @@ +package actest + +import "sync" + +const Concurrency = 10 +const BatchSize = 1000 + +type bounds struct { + start, end int +} + +// ConcurrentBatch spawns the requested amount of workers then ask them to run eachFn on chunks of the requested size +func ConcurrentBatch(workers, count, size int, eachFn func(start, end int) error) error { + var wg sync.WaitGroup + alldone := make(chan bool) // Indicates that all workers have finished working + chunk := make(chan bounds) // Gives the workers the bounds they should work with + ret := make(chan error) // Allow workers to notify in case of errors + defer close(ret) + + // Launch all workers + for x := 0; x < workers; x++ { + wg.Add(1) + go func() { + defer wg.Done() + for ck := range chunk { + if err := eachFn(ck.start, ck.end); err != nil { + ret <- err + return + } + } + }() + } + + go func() { + // Tell the workers the chunks they have to work on + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + + chunk <- bounds{start: i, end: end} + + i = end + } + close(chunk) + + // Wait for the workers + wg.Wait() + close(alldone) + }() + + // wait for an error or for all workers to be done + select { + case err := <-ret: + return err + case <-alldone: + break + } + return nil +} diff --git a/pkg/services/accesscontrol/api/api.go b/pkg/services/accesscontrol/api/api.go index a3d279e103f..b0f620dd31b 100644 --- a/pkg/services/accesscontrol/api/api.go +++ b/pkg/services/accesscontrol/api/api.go @@ -7,8 +7,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" ) @@ -47,7 +47,7 @@ func (api *AccessControlAPI) RegisterAPIEndpoints() { } // GET /api/access-control/user/actions -func (api *AccessControlAPI) getUserActions(c *models.ReqContext) response.Response { +func (api *AccessControlAPI) getUserActions(c *contextmodel.ReqContext) response.Response { reloadCache := c.QueryBool("reloadcache") permissions, err := api.Service.GetUserPermissions(c.Req.Context(), c.SignedInUser, ac.Options{ReloadCache: reloadCache}) @@ -59,7 +59,7 @@ func (api *AccessControlAPI) getUserActions(c *models.ReqContext) response.Respo } // GET /api/access-control/user/permissions -func (api *AccessControlAPI) getUserPermissions(c *models.ReqContext) response.Response { +func (api *AccessControlAPI) getUserPermissions(c *contextmodel.ReqContext) response.Response { reloadCache := c.QueryBool("reloadcache") permissions, err := api.Service.GetUserPermissions(c.Req.Context(), c.SignedInUser, ac.Options{ReloadCache: reloadCache}) @@ -71,7 +71,7 @@ func (api *AccessControlAPI) getUserPermissions(c *models.ReqContext) response.R } // GET /api/access-control/users/permissions -func (api *AccessControlAPI) searchUsersPermissions(c *models.ReqContext) response.Response { +func (api *AccessControlAPI) searchUsersPermissions(c *contextmodel.ReqContext) response.Response { searchOptions := ac.SearchOptions{ ActionPrefix: c.Query("actionPrefix"), Action: c.Query("action"), @@ -98,7 +98,7 @@ func (api *AccessControlAPI) searchUsersPermissions(c *models.ReqContext) respon } // GET /api/access-control/user/:userID/permissions/search -func (api *AccessControlAPI) searchUserPermissions(c *models.ReqContext) response.Response { +func (api *AccessControlAPI) searchUserPermissions(c *contextmodel.ReqContext) response.Response { userIDString := web.Params(c.Req)[":userID"] userID, err := strconv.ParseInt(userIDString, 10, 64) if err != nil { diff --git a/pkg/services/accesscontrol/checker.go b/pkg/services/accesscontrol/checker.go new file mode 100644 index 00000000000..1b0e7d48e03 --- /dev/null +++ b/pkg/services/accesscontrol/checker.go @@ -0,0 +1,57 @@ +package accesscontrol + +import ( + "github.com/grafana/grafana/pkg/services/user" +) + +func Checker(user *user.SignedInUser, action string) func(scopes ...string) bool { + if user.Permissions == nil || user.Permissions[user.OrgID] == nil { + return func(scopes ...string) bool { return false } + } + + userScopes, ok := user.Permissions[user.OrgID][action] + if !ok { + return func(scopes ...string) bool { return false } + } + + lookup := make(map[string]bool, len(userScopes)) + for i := range userScopes { + lookup[userScopes[i]] = true + } + + var checkedWildcards bool + var hasWildcard bool + + return func(scopes ...string) bool { + if !checkedWildcards { + wildcards := wildcardsFromScopes(scopes...) + for _, w := range wildcards { + if _, ok := lookup[w]; ok { + hasWildcard = true + break + } + } + checkedWildcards = true + } + + if hasWildcard { + return true + } + + for _, s := range scopes { + if lookup[s] { + return true + } + } + return false + } +} + +func wildcardsFromScopes(scopes ...string) Wildcards { + prefixes := make([]string, len(scopes)) + for _, scope := range scopes { + prefixes = append(prefixes, ScopePrefix(scope)) + } + + return WildcardsFromPrefixes(prefixes) +} diff --git a/pkg/services/accesscontrol/checker_test.go b/pkg/services/accesscontrol/checker_test.go new file mode 100644 index 00000000000..a9e5798978b --- /dev/null +++ b/pkg/services/accesscontrol/checker_test.go @@ -0,0 +1,111 @@ +package accesscontrol + +import ( + "strconv" + "testing" + + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/assert" +) + +type testData struct { + uid string + folderUid string +} + +func (d testData) Scopes() []string { + return []string{ + "dashboards:uid:" + d.uid, + "folders:uid:" + d.folderUid, + } +} + +func generateTestData() []testData { + var data []testData + for i := 1; i < 100; i++ { + data = append(data, testData{ + uid: strconv.Itoa(i), + folderUid: strconv.Itoa(i + 100), + }) + } + return data +} + +func Test_Checker(t *testing.T) { + data := generateTestData() + type testCase struct { + desc string + user *user.SignedInUser + expectedLen int + } + tests := []testCase{ + { + desc: "should pass for every entity with dashboard wildcard scope", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:*"}}}, + }, + expectedLen: len(data), + }, + { + desc: "should pass for every entity with folder wildcard scope", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"folders:*"}}}, + }, + expectedLen: len(data), + }, + { + desc: "should only pass for for 3 scopes", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:uid:4", "dashboards:uid:50", "dashboards:uid:99"}}}, + }, + expectedLen: 3, + }, + { + desc: "should only pass 4 with secondary supported scope", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"folders:uid:104", "folders:uid:150", "folders:uid:154", "folders:uid:199"}}}, + }, + expectedLen: 4, + }, + { + desc: "should only pass 4 with some dashboard and some folder scopes", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:uid:1", "dashboards:uid:2", "folders:uid:154", "folders:uid:199"}}}, + }, + expectedLen: 4, + }, + { + desc: "should only pass 2 with overlapping dashboard and folder scopes", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:uid:101", "dashboards:uid:2", "folders:uid:101", "folders:uid:102"}}}, + }, + expectedLen: 2, + }, + { + desc: "should pass none for missing action", + user: &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: {}}, + }, + expectedLen: 0, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + check := Checker(tt.user, "dashboards:read") + numPasses := 0 + for _, d := range data { + if ok := check(d.Scopes()...); ok { + numPasses++ + } + } + assert.Equal(t, tt.expectedLen, numPasses) + }) + } +} diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 6f14b6fd50e..fa40140caa4 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -10,9 +10,9 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" rs "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -229,7 +229,7 @@ func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, team, err := teamSvc.CreateTeam("team", "", orgID) require.NoError(t, err) - err = teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, models.PERMISSION_VIEW) + err = teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, dashboards.PERMISSION_VIEW) require.NoError(t, err) return user, team @@ -276,7 +276,7 @@ func createUsersAndTeams(t *testing.T, svcs helperServices, orgID int64, users [ team, err := svcs.teamSvc.CreateTeam(fmt.Sprintf("team%v", i+1), "", orgID) require.NoError(t, err) - err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, models.PERMISSION_VIEW) + err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.ID, false, dashboards.PERMISSION_VIEW) require.NoError(t, err) err = svcs.orgSvc.UpdateOrgUser(context.Background(), diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index 888fc2a615d..2c3f1299f59 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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -29,7 +29,7 @@ func Middleware(ac AccessControl) func(web.Handler, Evaluator) web.Handler { return fallback } - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if c.AllowAnonymous { forceLogin, _ := strconv.ParseBool(c.Req.URL.Query().Get("forceLogin")) // ignoring error, assuming false for non-true values is ok. orgID, err := strconv.ParseInt(c.Req.URL.Query().Get("orgId"), 10, 64) @@ -53,7 +53,7 @@ func Middleware(ac AccessControl) func(web.Handler, Evaluator) web.Handler { } } -func authorize(c *models.ReqContext, ac AccessControl, user *user.SignedInUser, evaluator Evaluator) { +func authorize(c *contextmodel.ReqContext, ac AccessControl, user *user.SignedInUser, evaluator Evaluator) { injected, err := evaluator.MutateScopes(c.Req.Context(), scopeInjector(scopeParams{ OrgID: c.OrgID, URLParams: web.Params(c.Req), @@ -70,7 +70,7 @@ func authorize(c *models.ReqContext, ac AccessControl, user *user.SignedInUser, } } -func deny(c *models.ReqContext, evaluator Evaluator, err error) { +func deny(c *contextmodel.ReqContext, evaluator Evaluator, err error) { id := newID() if err != nil { c.Logger.Error("Error from access control system", "error", err, "accessErrorID", id) @@ -106,7 +106,7 @@ func deny(c *models.ReqContext, evaluator Evaluator, err error) { }) } -func unauthorized(c *models.ReqContext, err error) { +func unauthorized(c *contextmodel.ReqContext, err error) { if c.IsApiRequest() { response := map[string]interface{}{ "message": "Unauthorized", @@ -129,7 +129,7 @@ func unauthorized(c *models.ReqContext, err error) { c.Redirect(setting.AppSubUrl + "/login") } -func writeRedirectCookie(c *models.ReqContext) { +func writeRedirectCookie(c *contextmodel.ReqContext) { redirectTo := c.Req.RequestURI if setting.AppSubUrl != "" && !strings.HasPrefix(redirectTo, setting.AppSubUrl) { redirectTo = setting.AppSubUrl + c.Req.RequestURI @@ -159,7 +159,7 @@ func newID() string { return "ACE" + id } -type OrgIDGetter func(c *models.ReqContext) (int64, error) +type OrgIDGetter func(c *contextmodel.ReqContext) (int64, error) type userCache interface { GetSignedInUserWithCacheCtx(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) @@ -171,7 +171,7 @@ func AuthorizeInOrgMiddleware(ac AccessControl, service Service, cache userCache return fallback } - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { // using a copy of the user not to modify the signedInUser, yet perform the permission evaluation in another org userCopy := *(c.SignedInUser) orgID, err := getTargetOrg(c) @@ -211,7 +211,7 @@ func AuthorizeInOrgMiddleware(ac AccessControl, service Service, cache userCache } } -func UseOrgFromContextParams(c *models.ReqContext) (int64, error) { +func UseOrgFromContextParams(c *contextmodel.ReqContext) (int64, error) { orgID, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) // Special case of macaron handling invalid params @@ -222,12 +222,12 @@ func UseOrgFromContextParams(c *models.ReqContext) (int64, error) { return orgID, nil } -func UseGlobalOrg(c *models.ReqContext) (int64, error) { +func UseGlobalOrg(c *contextmodel.ReqContext) (int64, error) { return GlobalOrgID, nil } func LoadPermissionsMiddleware(service Service) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if service.IsDisabled() { return } diff --git a/pkg/services/accesscontrol/middleware_test.go b/pkg/services/accesscontrol/middleware_test.go index 7ba3e180126..c9f3626058c 100644 --- a/pkg/services/accesscontrol/middleware_test.go +++ b/pkg/services/accesscontrol/middleware_test.go @@ -8,10 +8,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" ) @@ -55,7 +55,7 @@ func TestMiddleware(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { fallbackCalled := false - fallback := func(c *models.ReqContext) { + fallback := func(c *contextmodel.ReqContext) { fallbackCalled = true } @@ -66,7 +66,7 @@ func TestMiddleware(t *testing.T) { server.Use(accesscontrol.Middleware(test.ac)(fallback, test.evaluator)) endpointCalled := false - server.Get("/", func(c *models.ReqContext) { + server.Get("/", func(c *contextmodel.ReqContext) { endpointCalled = true c.Resp.WriteHeader(http.StatusOK) }) @@ -99,13 +99,13 @@ func TestMiddleware_forceLogin(t *testing.T) { server := web.New() server.UseMiddleware(web.Renderer("../../public/views", "[[", "]]")) - server.Get("/endpoint", func(c *models.ReqContext) { + server.Get("/endpoint", func(c *contextmodel.ReqContext) { endpointCalled = true c.Resp.WriteHeader(http.StatusOK) }) ac := mock.New().WithPermissions([]accesscontrol.Permission{{Action: "endpoint:read", Scope: "endpoint:1"}}) - server.Use(contextProvider(func(c *models.ReqContext) { + server.Use(contextProvider(func(c *contextmodel.ReqContext) { c.AllowAnonymous = true c.SignedInUser.IsAnonymous = true c.IsSignedIn = false @@ -129,9 +129,9 @@ func TestMiddleware_forceLogin(t *testing.T) { } } -func contextProvider(modifiers ...func(c *models.ReqContext)) web.Handler { +func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler { return func(c *web.Context) { - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: c, Logger: log.New(""), SignedInUser: &user.SignedInUser{}, diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index a4ede7777ab..81ed7e0ca01 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/dashboards" @@ -85,7 +84,7 @@ func ProvideTeamPermissions( case "Member": return teamimpl.AddOrUpdateTeamMemberHook(session, user.ID, orgID, teamId, user.IsExternal, 0) case "Admin": - return teamimpl.AddOrUpdateTeamMemberHook(session, user.ID, orgID, teamId, user.IsExternal, models.PERMISSION_ADMIN) + return teamimpl.AddOrUpdateTeamMemberHook(session, user.ID, orgID, teamId, user.IsExternal, dashboards.PERMISSION_ADMIN) case "": return teamimpl.RemoveTeamMemberHook(session, &team.RemoveTeamMemberCommand{ OrgID: orgID, @@ -120,10 +119,11 @@ func ProvideDashboardPermissions( ) (*DashboardPermissionsService, error) { getDashboard := func(ctx context.Context, orgID int64, resourceID string) (*dashboards.Dashboard, error) { query := &dashboards.GetDashboardQuery{UID: resourceID, OrgID: orgID} - if _, err := dashboardStore.GetDashboard(ctx, query); err != nil { + queryResult, err := dashboardStore.GetDashboard(ctx, query) + if err != nil { return nil, err } - return query.Result, nil + return queryResult, nil } options := resourcepermissions.Options{ @@ -148,10 +148,11 @@ func ProvideDashboardPermissions( } if dashboard.FolderID > 0 { query := &dashboards.GetDashboardQuery{ID: dashboard.FolderID, OrgID: orgID} - if _, err := dashboardStore.GetDashboard(ctx, query); err != nil { + queryResult, err := dashboardStore.GetDashboard(ctx, query) + if err != nil { return nil, err } - return []string{dashboards.ScopeFoldersProvider.GetResourceScopeUID(query.Result.UID)}, nil + return []string{dashboards.ScopeFoldersProvider.GetResourceScopeUID(queryResult.UID)}, nil } return []string{}, nil }, @@ -202,11 +203,12 @@ func ProvideFolderPermissions( ResourceAttribute: "uid", ResourceValidator: func(ctx context.Context, orgID int64, resourceID string) error { query := &dashboards.GetDashboardQuery{UID: resourceID, OrgID: orgID} - if _, err := dashboardStore.GetDashboard(ctx, query); err != nil { + queryResult, err := dashboardStore.GetDashboard(ctx, query) + if err != nil { return err } - if !query.Result.IsFolder { + if !queryResult.IsFolder { return errors.New("not found") } diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index 094221df7e6..71a0a136aa8 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" ) @@ -68,7 +68,7 @@ type Description struct { Permissions []string `json:"permissions"` } -func (a *api) getDescription(c *models.ReqContext) response.Response { +func (a *api) getDescription(c *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, &Description{ Permissions: a.permissions, Assignments: a.service.options.Assignments, @@ -91,7 +91,7 @@ type resourcePermissionDTO struct { Permission string `json:"permission"` } -func (a *api) getPermissions(c *models.ReqContext) response.Response { +func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] permissions, err := a.service.GetPermissions(c.Req.Context(), c.SignedInUser, resourceID) @@ -144,7 +144,7 @@ type setPermissionsCommand struct { Permissions []accesscontrol.SetResourcePermissionCommand `json:"permissions"` } -func (a *api) setUserPermission(c *models.ReqContext) response.Response { +func (a *api) setUserPermission(c *contextmodel.ReqContext) response.Response { userID, err := strconv.ParseInt(web.Params(c.Req)[":userID"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "userID is invalid", err) @@ -164,7 +164,7 @@ func (a *api) setUserPermission(c *models.ReqContext) response.Response { return permissionSetResponse(cmd) } -func (a *api) setTeamPermission(c *models.ReqContext) response.Response { +func (a *api) setTeamPermission(c *contextmodel.ReqContext) response.Response { teamID, err := strconv.ParseInt(web.Params(c.Req)[":teamID"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "teamID is invalid", err) @@ -184,7 +184,7 @@ func (a *api) setTeamPermission(c *models.ReqContext) response.Response { return permissionSetResponse(cmd) } -func (a *api) setBuiltinRolePermission(c *models.ReqContext) response.Response { +func (a *api) setBuiltinRolePermission(c *contextmodel.ReqContext) response.Response { builtInRole := web.Params(c.Req)[":builtInRole"] resourceID := web.Params(c.Req)[":resourceID"] @@ -201,7 +201,7 @@ func (a *api) setBuiltinRolePermission(c *models.ReqContext) response.Response { return permissionSetResponse(cmd) } -func (a *api) setPermissions(c *models.ReqContext) response.Response { +func (a *api) setPermissions(c *contextmodel.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] cmd := setPermissionsCommand{} diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 955f30a1189..06a918738af 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -15,9 +15,9 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -444,7 +444,7 @@ type testContext struct { func contextProvider(tc *testContext) web.Handler { return func(c *web.Context) { signedIn := tc.user != nil - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: c, SignedInUser: tc.user, IsSignedIn: signedIn, diff --git a/pkg/services/accesscontrol/resourcepermissions/middleware.go b/pkg/services/accesscontrol/resourcepermissions/middleware.go index fead18fb9b8..7acb80d3392 100644 --- a/pkg/services/accesscontrol/resourcepermissions/middleware.go +++ b/pkg/services/accesscontrol/resourcepermissions/middleware.go @@ -3,12 +3,12 @@ package resourcepermissions import ( "net/http" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) func disableMiddleware(shouldDisable bool) web.Handler { - return func(c *models.ReqContext) { + return func(c *contextmodel.ReqContext) { if shouldDisable { c.Resp.WriteHeader(http.StatusNotFound) return @@ -16,4 +16,4 @@ func disableMiddleware(shouldDisable bool) web.Handler { } } -func nopMiddleware(c *models.ReqContext) {} +func nopMiddleware(c *contextmodel.ReqContext) {} diff --git a/pkg/services/accesscontrol/scope.go b/pkg/services/accesscontrol/scope.go index 8b58b9bd8d2..4805b25f2a1 100644 --- a/pkg/services/accesscontrol/scope.go +++ b/pkg/services/accesscontrol/scope.go @@ -142,19 +142,26 @@ func (s scopeProviderImpl) GetResourceAllIDScope() string { return GetResourceAllIDScope(s.root) } -// WildcardsFromPrefix generates valid wildcards from prefix -// datasource:uid: => "*", "datasource:*", "datasource:uid:*" func WildcardsFromPrefix(prefix string) Wildcards { + return WildcardsFromPrefixes([]string{prefix}) +} + +// WildcardsFromPrefixes generates valid wildcards from prefixes +// datasource:uid: => "*", "datasource:*", "datasource:uid:*" +func WildcardsFromPrefixes(prefixes []string) Wildcards { var b strings.Builder wildcards := Wildcards{"*"} - parts := strings.Split(prefix, ":") - for _, p := range parts { - if p == "" { - continue + for _, prefix := range prefixes { + parts := strings.Split(prefix, ":") + for _, p := range parts { + if p == "" { + continue + } + b.WriteString(p) + b.WriteRune(':') + wildcards = append(wildcards, b.String()+"*") } - b.WriteString(p) - b.WriteRune(':') - wildcards = append(wildcards, b.String()+"*") + b.Reset() } return wildcards } diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index d5375cae94d..ab31688aa9e 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -123,11 +123,12 @@ func (c *EvalContext) GetDashboardUID() (*dashboards.DashboardRef, error) { } uidQuery := &dashboards.GetDashboardRefByIDQuery{ID: c.Rule.DashboardID} - if err := c.dashboardService.GetDashboardUIDByID(c.Ctx, uidQuery); err != nil { + uidQueryResult, err := c.dashboardService.GetDashboardUIDByID(c.Ctx, uidQuery) + if err != nil { return nil, err } - c.dashboardRef = uidQuery.Result + c.dashboardRef = uidQueryResult return c.dashboardRef, nil } diff --git a/pkg/services/alerting/store.go b/pkg/services/alerting/store.go index ffbe00f30c9..0ab879af213 100644 --- a/pkg/services/alerting/store.go +++ b/pkg/services/alerting/store.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" @@ -159,7 +159,7 @@ func (ss *sqlStore) HandleAlertsQuery(ctx context.Context, query *alertmodels.Ge } if query.User.OrgRole != org.RoleAdmin { - builder.WriteDashboardPermissionFilter(query.User, models.PERMISSION_VIEW) + builder.WriteDashboardPermissionFilter(query.User, dashboards.PERMISSION_VIEW) } builder.Write(" ORDER BY name ASC") diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index 33f75061479..bcb4675411d 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -10,9 +10,9 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -347,7 +347,7 @@ func getAccessControlFilter(user *user.SignedInUser) (string, []interface{}, err } // annotation read permission with scope annotations:type:dashboard allows listing annotations from dashboards which the user can view if t == annotations.Dashboard.String() { - dashboardFilter, dashboardParams := permissions.NewAccessControlDashboardPermissionFilter(user, models.PERMISSION_VIEW, searchstore.TypeDashboard).Where() + dashboardFilter, dashboardParams := permissions.NewAccessControlDashboardPermissionFilter(user, dashboards.PERMISSION_VIEW, searchstore.TypeDashboard).Where() filter := fmt.Sprintf("a.dashboard_id IN(SELECT id FROM dashboard WHERE %s)", dashboardFilter) filters = append(filters, filter) params = dashboardParams diff --git a/pkg/services/auth/authtest/testing.go b/pkg/services/auth/authtest/testing.go index d2bbd09b46b..6a924c5ebf4 100644 --- a/pkg/services/auth/authtest/testing.go +++ b/pkg/services/auth/authtest/testing.go @@ -5,11 +5,12 @@ import ( "net" "time" - "github.com/grafana/grafana/pkg/models" + "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" - "golang.org/x/oauth2" ) type FakeUserAuthTokenService struct { @@ -112,7 +113,7 @@ func (s *FakeUserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, type FakeOAuthTokenService struct { passThruEnabled bool - ExpectedAuthUser *models.UserAuth + ExpectedAuthUser *login.UserAuth ExpectedErrors map[string]error } @@ -129,7 +130,7 @@ func (ts *FakeOAuthTokenService) IsOAuthPassThruEnabled(*datasources.DataSource) return ts.passThruEnabled } -func (ts *FakeOAuthTokenService) HasOAuthEntry(context.Context, *user.SignedInUser) (*models.UserAuth, bool, error) { +func (ts *FakeOAuthTokenService) HasOAuthEntry(context.Context, *user.SignedInUser) (*login.UserAuth, bool, error) { if ts.ExpectedAuthUser != nil { return ts.ExpectedAuthUser, true, nil } @@ -139,14 +140,14 @@ func (ts *FakeOAuthTokenService) HasOAuthEntry(context.Context, *user.SignedInUs return nil, false, nil } -func (ts *FakeOAuthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *models.UserAuth) error { +func (ts *FakeOAuthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth) error { ts.ExpectedAuthUser.OAuthAccessToken = "" ts.ExpectedAuthUser.OAuthRefreshToken = "" ts.ExpectedAuthUser.OAuthExpiry = time.Time{} return nil } -func (ts *FakeOAuthTokenService) TryTokenRefresh(ctx context.Context, usr *models.UserAuth) error { +func (ts *FakeOAuthTokenService) TryTokenRefresh(ctx context.Context, usr *login.UserAuth) error { if err, ok := ts.ExpectedErrors["TryTokenRefresh"]; ok { return err } diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 4ebfd517ba3..64522ff26b5 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -10,8 +10,8 @@ import ( "golang.org/x/oauth2" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" @@ -45,30 +45,40 @@ type ClientParams struct { // EnableDisabledUsers is a hint to the auth service that it should reenable disabled users EnableDisabledUsers bool // LookUpParams are the arguments used to look up the entity in the DB. - LookUpParams models.UserLookupParams + LookUpParams login.UserLookupParams } type PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error type PostLoginHookFn func(ctx context.Context, identity *Identity, r *Request, err error) type Service interface { - // Authenticate authenticates a request using the specified client. - Authenticate(ctx context.Context, client string, r *Request) (*Identity, bool, error) - // RegisterPostAuthHook registers a hook that is called after a successful authentication. - RegisterPostAuthHook(hook PostAuthHookFn) + // Authenticate authenticates a request + Authenticate(ctx context.Context, r *Request) (*Identity, error) + // RegisterPostAuthHook registers a hook with a priority that is called after a successful authentication. + // A lower number means higher priority. + RegisterPostAuthHook(hook PostAuthHookFn, priority uint) // Login authenticates a request and creates a session on successful authentication. Login(ctx context.Context, client string, r *Request) (*Identity, error) // RegisterPostLoginHook registers a hook that that is called after a login request. - RegisterPostLoginHook(hook PostLoginHookFn) + // A lower number means higher priority. + RegisterPostLoginHook(hook PostLoginHookFn, priority uint) // RedirectURL will generate url that we can use to initiate auth flow for supported clients. RedirectURL(ctx context.Context, client string, r *Request) (string, error) } type Client interface { + // Name returns the name of a client + Name() string // Authenticate performs the authentication for the request Authenticate(ctx context.Context, r *Request) (*Identity, error) +} + +type ContextAwareClient interface { + Client // Test should return true if client can be used to authenticate request Test(ctx context.Context, r *Request) bool + // Priority for the client, a lower number means higher priority + Priority() uint } type RedirectClient interface { @@ -132,6 +142,8 @@ type Identity struct { // Namespace* constants. For example, "user:1" or "api-key:1". // If the entity is not found in the DB or this entity is non-persistent, this field will be empty. ID string + // IsAnonymous + IsAnonymous bool // Login is the short hand identifier of the entity. Should be unique. Login string // Name is the display name of the entity. It is not guaranteed to be unique. @@ -171,11 +183,6 @@ func (i *Identity) Role() org.RoleType { return i.OrgRoles[i.OrgID] } -// IsAnonymous will return true if no ID is set on the identity -func (i *Identity) IsAnonymous() bool { - return i.ID == "" -} - // TODO: improve error handling func (i *Identity) NamespacedID() (string, int64) { var ( @@ -222,7 +229,7 @@ func (i *Identity) SignedInUser() *user.SignedInUser { Email: i.Email, OrgCount: i.OrgCount, IsGrafanaAdmin: isGrafanaAdmin, - IsAnonymous: i.IsAnonymous(), + IsAnonymous: i.IsAnonymous, IsDisabled: i.IsDisabled, HelpFlags1: i.HelpFlags1, LastSeenAt: i.LastSeenAt, @@ -240,9 +247,9 @@ func (i *Identity) SignedInUser() *user.SignedInUser { return u } -func (i *Identity) ExternalUserInfo() models.ExternalUserInfo { +func (i *Identity) ExternalUserInfo() login.ExternalUserInfo { _, id := i.NamespacedID() - return models.ExternalUserInfo{ + return login.ExternalUserInfo{ OAuthToken: i.OAuthToken, AuthModule: i.AuthModule, AuthId: i.AuthID, diff --git a/pkg/services/authn/authnimpl/priority_queue.go b/pkg/services/authn/authnimpl/priority_queue.go new file mode 100644 index 00000000000..d06ae02de90 --- /dev/null +++ b/pkg/services/authn/authnimpl/priority_queue.go @@ -0,0 +1,34 @@ +package authnimpl + +func newQueue[T any]() *queue[T] { + return &queue[T]{items: []queueItem[T]{}} +} + +type queue[T any] struct { + items []queueItem[T] +} + +type queueItem[T any] struct { + v T + p uint +} + +func (q *queue[T]) insert(v T, p uint) { + // no items in the queue so we just add it + if len(q.items) == 0 { + q.items = append(q.items, queueItem[T]{v, p}) + return + } + + // find the position in the queue the item should be placed based on priority + for i, item := range q.items { + if p < item.p { + q.items = append(q.items[:i+1], q.items[i:]...) + q.items[i] = queueItem[T]{v, p} + return + } + } + + // item did not have higher priority then what is in the queue currently, so we need to add it to the end + q.items = append(q.items, queueItem[T]{v, p}) +} diff --git a/pkg/services/authn/authnimpl/priority_queue_test.go b/pkg/services/authn/authnimpl/priority_queue_test.go new file mode 100644 index 00000000000..119e3a9e5b8 --- /dev/null +++ b/pkg/services/authn/authnimpl/priority_queue_test.go @@ -0,0 +1,48 @@ +package authnimpl + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/authn" +) + +func TestQueue(t *testing.T) { + type testCase struct { + desc string + clients []authn.ContextAwareClient + expectedOrder []string + } + + tests := []testCase{ + { + desc: "expect correct order", + clients: []authn.ContextAwareClient{ + &authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 1}, + &authntest.FakeClient{ExpectedName: "5", ExpectedPriority: 5}, + &authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3}, + &authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 2}, + &authntest.FakeClient{ExpectedName: "4", ExpectedPriority: 4}, + }, + expectedOrder: []string{"1", "2", "3", "4", "5"}, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + q := newQueue[authn.ContextAwareClient]() + for _, c := range tt.clients { + q.insert(c, c.Priority()) + } + + require.Len(t, q.items, len(tt.expectedOrder)) + + for i := range q.items { + assert.Equal(t, q.items[i].v.Name(), tt.expectedOrder[i]) + } + }) + } +} diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 937624a1f16..cba480ee297 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "github.com/hashicorp/go-multierror" "go.opentelemetry.io/otel/attribute" "github.com/grafana/grafana/pkg/infra/log" @@ -34,7 +35,8 @@ const ( ) var ( - errDisabledIdentity = errutil.NewBase(errutil.StatusUnauthorized, "identity.disabled") + errCantAuthenticateReq = errutil.NewBase(errutil.StatusUnauthorized, "auth.unauthorized") + errDisabledIdentity = errutil.NewBase(errutil.StatusUnauthorized, "identity.disabled") ) // make sure service implements authn.Service interface @@ -55,20 +57,24 @@ func ProvideService( log: log.New("authn.service"), cfg: cfg, clients: make(map[string]authn.Client), + clientQueue: newQueue[authn.ContextAwareClient](), tracer: tracer, sessionService: sessionService, - postAuthHooks: []authn.PostAuthHookFn{}, + postAuthHooks: newQueue[authn.PostAuthHookFn](), + postLoginHooks: newQueue[authn.PostLoginHookFn](), } - s.clients[authn.ClientRender] = clients.ProvideRender(userService, renderService) - s.clients[authn.ClientAPIKey] = clients.ProvideAPIKey(apikeyService, userService) + s.RegisterClient(clients.ProvideRender(userService, renderService)) + s.RegisterClient(clients.ProvideAPIKey(apikeyService, userService)) - sessionClient := clients.ProvideSession(sessionService, userService, cfg.LoginCookieName, cfg.LoginMaxLifetime) - s.clients[authn.ClientSession] = sessionClient - s.RegisterPostAuthHook(sessionClient.RefreshTokenHook) + if cfg.LoginCookieName != "" { + sessionClient := clients.ProvideSession(sessionService, userService, cfg.LoginCookieName, cfg.LoginMaxLifetime) + s.RegisterClient(sessionClient) + s.RegisterPostAuthHook(sessionClient.RefreshTokenHook, 20) + } if s.cfg.AnonymousEnabled { - s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService) + s.RegisterClient(clients.ProvideAnonymous(cfg, orgService)) } var proxyClients []authn.ProxyClient @@ -89,11 +95,11 @@ func ProvideService( if len(passwordClients) > 0 { passwordClient := clients.ProvidePassword(loginAttempts, passwordClients...) if s.cfg.BasicAuthEnabled { - s.clients[authn.ClientBasic] = clients.ProvideBasic(passwordClient) + s.RegisterClient(clients.ProvideBasic(passwordClient)) } // FIXME (kalleep): Remove the global variable and stick it into cfg if !setting.DisableLoginForm { - s.clients[authn.ClientForm] = clients.ProvideForm(passwordClient) + s.RegisterClient(clients.ProvideForm(passwordClient)) } } @@ -102,98 +108,112 @@ func ProvideService( if err != nil { s.log.Error("failed to configure auth proxy", "err", err) } else { - s.clients[authn.ClientProxy] = proxy + s.RegisterClient(proxy) } } if s.cfg.JWTAuthEnabled { - s.clients[authn.ClientJWT] = clients.ProvideJWT(jwtService, cfg) + s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) } // FIXME (jguer): move to User package userSyncService := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService) orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService) - s.RegisterPostAuthHook(userSyncService.SyncUser) - s.RegisterPostAuthHook(orgUserSyncService.SyncOrgUser) - s.RegisterPostAuthHook(sync.ProvideUserLastSeenSync(userService).SyncLastSeen) - s.RegisterPostAuthHook(sync.ProvideAPIKeyLastSeenSync(apikeyService).SyncLastSeen) + s.RegisterPostAuthHook(userSyncService.SyncUser, 10) + s.RegisterPostAuthHook(orgUserSyncService.SyncOrgUser, 30) + s.RegisterPostAuthHook(sync.ProvideUserLastSeenSync(userService).SyncLastSeen, 40) + s.RegisterPostAuthHook(sync.ProvideAPIKeyLastSeenSync(apikeyService).SyncLastSeen, 50) if features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { - s.RegisterPostAuthHook(sync.ProvideOauthTokenSync(oauthTokenService, sessionService).SyncOauthToken) + s.RegisterPostAuthHook(sync.ProvideOauthTokenSync(oauthTokenService, sessionService).SyncOauthToken, 60) } return s } type Service struct { - log log.Logger - cfg *setting.Cfg - clients map[string]authn.Client + log log.Logger + cfg *setting.Cfg + + clients map[string]authn.Client + clientQueue *queue[authn.ContextAwareClient] tracer tracing.Tracer sessionService auth.UserTokenService // postAuthHooks are called after a successful authentication. They can modify the identity. - postAuthHooks []authn.PostAuthHookFn + postAuthHooks *queue[authn.PostAuthHookFn] // postLoginHooks are called after a login request is performed, both for failing and successful requests. - postLoginHooks []authn.PostLoginHookFn + postLoginHooks *queue[authn.PostLoginHookFn] } -func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Request) (*authn.Identity, bool, error) { - c, ok := s.clients[client] - if !ok { - return nil, false, nil - } - - if !c.Test(ctx, r) { - return nil, false, nil - } - +func (s *Service) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { ctx, span := s.tracer.Start(ctx, "authn.Authenticate") defer span.End() - span.SetAttributes("authn.client", client, attribute.Key("authn.client").String(client)) + var authErr error + for _, item := range s.clientQueue.items { + if item.v.Test(ctx, r) { + identity, err := s.authenticate(ctx, item.v, r) + if err != nil { + s.log.Warn("failed to authenticate", "client", item.v.Name(), "err", err) + authErr = multierror.Append(authErr, err) + // try next + continue + } + + if identity != nil { + return identity, nil + } + } + } + + if authErr != nil { + return nil, authErr + } + + return nil, errCantAuthenticateReq.Errorf("cannot authenticate request") +} + +func (s *Service) authenticate(ctx context.Context, c authn.Client, r *authn.Request) (*authn.Identity, error) { r.OrgID = orgIDFromRequest(r) identity, err := c.Authenticate(ctx, r) if err != nil { - s.log.FromContext(ctx).Warn("auth client could not authenticate request", "client", client, "error", err) - span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client could not authenticate request"}}) - return nil, true, err + s.log.FromContext(ctx).Warn("auth client could not authenticate request", "client", c.Name(), "error", err) + return nil, err } - // FIXME (kalleep): Handle disabled identities - - for _, hook := range s.postAuthHooks { - if err := hook(ctx, identity, r); err != nil { + for _, hook := range s.postAuthHooks.items { + if err := hook.v(ctx, identity, r); err != nil { s.log.FromContext(ctx).Warn("post auth hook failed", "error", err, "id", identity) - return nil, false, err + return nil, err } } if identity.IsDisabled { - return nil, true, errDisabledIdentity.Errorf("identity is disabled") + return nil, errDisabledIdentity.Errorf("identity is disabled") } - return identity, true, nil + return identity, nil } -func (s *Service) RegisterPostAuthHook(hook authn.PostAuthHookFn) { - s.postAuthHooks = append(s.postAuthHooks, hook) +func (s *Service) RegisterPostAuthHook(hook authn.PostAuthHookFn, priority uint) { + s.postAuthHooks.insert(hook, priority) } func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (identity *authn.Identity, err error) { - var ok bool - identity, ok, err = s.Authenticate(ctx, client, r) + defer func() { + for _, hook := range s.postLoginHooks.items { + hook.v(ctx, identity, r, err) + } + }() + + c, ok := s.clients[client] if !ok { return nil, authn.ErrClientNotConfigured.Errorf("client not configured: %s", client) } - defer func() { - for _, hook := range s.postLoginHooks { - hook(ctx, identity, r, err) - } - }() - + identity, err = s.authenticate(ctx, c, r) if err != nil { return nil, err } @@ -220,8 +240,8 @@ func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (i return identity, nil } -func (s *Service) RegisterPostLoginHook(hook authn.PostLoginHookFn) { - s.postLoginHooks = append(s.postLoginHooks, hook) +func (s *Service) RegisterPostLoginHook(hook authn.PostLoginHookFn, priority uint) { + s.postLoginHooks.insert(hook, priority) } func (s *Service) RedirectURL(ctx context.Context, client string, r *authn.Request) (string, error) { @@ -242,6 +262,13 @@ func (s *Service) RedirectURL(ctx context.Context, client string, r *authn.Reque return redirectClient.RedirectURL(ctx, r) } +func (s *Service) RegisterClient(c authn.Client) { + s.clients[c.Name()] = c + if cac, ok := c.(authn.ContextAwareClient); ok { + s.clientQueue.insert(cac, cac.Priority()) + } +} + func orgIDFromRequest(r *authn.Request) int64 { if r.HTTPRequest == nil { return 0 diff --git a/pkg/services/authn/authnimpl/service_test.go b/pkg/services/authn/authnimpl/service_test.go index 20c95c40b78..a778242b2c2 100644 --- a/pkg/services/authn/authnimpl/service_test.go +++ b/pkg/services/authn/authnimpl/service_test.go @@ -8,76 +8,106 @@ import ( "net/url" "testing" - "github.com/grafana/grafana/pkg/services/auth" - "github.com/grafana/grafana/pkg/services/auth/authtest" - "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) func TestService_Authenticate(t *testing.T) { type TestCase struct { - desc string - clientName string - clientErr error - clientIdentity *authn.Identity - expectedOK bool - expectedErr error + desc string + clients []authn.Client + expectedIdentity *authn.Identity + expectedErrors []error } - var clientErr = errors.New("some err") + var ( + firstErr = errors.New("first") + lastErr = errors.New("last") + ) tests := []TestCase{ { - desc: "should succeed with authentication for configured client", - clientIdentity: &authn.Identity{}, - clientName: "fake", - expectedOK: true, + desc: "should succeed with authentication for configured client", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedTest: true, ExpectedIdentity: &authn.Identity{ID: "user:1"}}, + }, + expectedIdentity: &authn.Identity{ID: "user:1"}, }, { - desc: "should return false when client is not configured", - clientName: "gitlab", - expectedOK: false, + desc: "should succeed with authentication for second client when first test fail", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 1, ExpectedTest: false}, + &authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 2, ExpectedTest: true, ExpectedIdentity: &authn.Identity{ID: "user:2"}}, + }, + expectedIdentity: &authn.Identity{ID: "user:2"}, }, { - desc: "should return true and error when client could be used but failed to authenticate", - clientName: "fake", - expectedOK: true, - clientErr: clientErr, - expectedErr: clientErr, + desc: "should succeed with authentication for third client when error happened in first", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false}, + &authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: true, ExpectedErr: errors.New("some error")}, + &authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: true, ExpectedIdentity: &authn.Identity{ID: "user:3"}}, + }, + expectedIdentity: &authn.Identity{ID: "user:3"}, }, { - desc: "should return error if identity is disabled", - clientName: "fake", - clientIdentity: &authn.Identity{IsDisabled: true}, - expectedOK: true, - expectedErr: errDisabledIdentity, + desc: "should return error when no client could authenticate the request", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false}, + &authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: false}, + &authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: false}, + }, + expectedErrors: []error{errCantAuthenticateReq}, + }, + { + desc: "should return all errors in chain", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false}, + &authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: true, ExpectedErr: firstErr}, + &authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: true, ExpectedErr: lastErr}, + }, + expectedErrors: []error{firstErr, lastErr}, + }, + { + desc: "should return error on disabled identity", + clients: []authn.Client{ + &authntest.FakeClient{ExpectedName: "1", ExpectedTest: true, ExpectedIdentity: &authn.Identity{IsDisabled: true}}, + }, + expectedErrors: []error{errDisabledIdentity}, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { svc := setupTests(t, func(svc *Service) { - svc.clients["fake"] = &authntest.FakeClient{ - ExpectedIdentity: tt.clientIdentity, - ExpectedErr: tt.clientErr, - ExpectedTest: tt.expectedOK, + for _, c := range tt.clients { + svc.RegisterClient(c) } }) - _, ok, err := svc.Authenticate(context.Background(), tt.clientName, &authn.Request{}) - assert.Equal(t, tt.expectedOK, ok) - assert.ErrorIs(t, err, tt.expectedErr) + identity, err := svc.Authenticate(context.Background(), &authn.Request{}) + if len(tt.expectedErrors) == 0 { + assert.NoError(t, err) + assert.EqualValues(t, tt.expectedIdentity, identity) + } else { + for _, e := range tt.expectedErrors { + assert.ErrorIs(t, err, e) + } + assert.Nil(t, identity) + } }) } } -func TestService_AuthenticateOrgID(t *testing.T) { +func TestService_Authenticate_OrgID(t *testing.T) { type TestCase struct { desc string req *authn.Request @@ -123,18 +153,16 @@ func TestService_AuthenticateOrgID(t *testing.T) { t.Run(tt.desc, func(t *testing.T) { var calledWith int64 s := setupTests(t, func(svc *Service) { - svc.clients["fake"] = authntest.MockClient{ + svc.RegisterClient(authntest.MockClient{ AuthenticateFunc: func(ctx context.Context, r *authn.Request) (*authn.Identity, error) { calledWith = r.OrgID return &authn.Identity{}, nil }, - TestFunc: func(ctx context.Context, r *authn.Request) bool { - return true - }, - } + TestFunc: func(ctx context.Context, r *authn.Request) bool { return true }, + }) }) - _, _, _ = s.Authenticate(context.Background(), "fake", tt.req) + _, _ = s.Authenticate(context.Background(), tt.req) assert.Equal(t, tt.expectedOrgID, calledWith) }) } @@ -157,7 +185,7 @@ func TestService_Login(t *testing.T) { tests := []TestCase{ { - desc: "should authenticate and create session for valid request", + desc: "should login for valid request", client: "fake", expectedClientOK: true, expectedClientIdentity: &authn.Identity{ @@ -169,12 +197,12 @@ func TestService_Login(t *testing.T) { }, }, { - desc: "should not authenticate with invalid client", + desc: "should not login with invalid client", client: "invalid", expectedErr: authn.ErrClientNotConfigured, }, { - desc: "should not authenticate non user identity", + desc: "should not login non user identity", client: "fake", expectedClientOK: true, expectedClientIdentity: &authn.Identity{ID: "apikey:1"}, @@ -185,11 +213,12 @@ func TestService_Login(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { s := setupTests(t, func(svc *Service) { - svc.clients["fake"] = &authntest.FakeClient{ + svc.RegisterClient(&authntest.FakeClient{ + ExpectedName: "fake", ExpectedErr: tt.expectedClientErr, ExpectedTest: tt.expectedClientOK, ExpectedIdentity: tt.expectedClientIdentity, - } + }) svc.sessionService = &authtest.FakeUserAuthTokenService{ CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { if tt.expectedSessionErr != nil { @@ -240,10 +269,8 @@ func TestService_RedirectURL(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service := setupTests(t, func(svc *Service) { - svc.clients["redirect"] = authntest.FakeRedirectClient{ - ExpectedURL: tt.expectedURL, - } - svc.clients["non-redirect"] = &authntest.FakeClient{} + svc.RegisterClient(authntest.FakeRedirectClient{ExpectedName: "redirect", ExpectedURL: tt.expectedURL}) + svc.RegisterClient(&authntest.FakeClient{ExpectedName: "non-redirect"}) }) u, err := service.RedirectURL(context.Background(), tt.client, nil) @@ -265,10 +292,13 @@ func setupTests(t *testing.T, opts ...func(svc *Service)) *Service { t.Helper() s := &Service{ - log: log.NewNopLogger(), - cfg: setting.NewCfg(), - clients: map[string]authn.Client{}, - tracer: tracing.InitializeTracerForTest(), + log: log.NewNopLogger(), + cfg: setting.NewCfg(), + clients: map[string]authn.Client{}, + clientQueue: newQueue[authn.ContextAwareClient](), + tracer: tracing.InitializeTracerForTest(), + postAuthHooks: newQueue[authn.PostAuthHookFn](), + postLoginHooks: newQueue[authn.PostLoginHookFn](), } for _, o := range opts { diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go index 27a06408674..a180cab31a1 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/assert" "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/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" "github.com/grafana/grafana/pkg/services/user" ) @@ -22,7 +22,7 @@ func TestOauthTokenSync_SyncOauthToken(t *testing.T) { desc string identity *authn.Identity - expectedHasEntryToken *models.UserAuth + expectedHasEntryToken *login.UserAuth expectHasEntryCalled bool expectedTryRefreshErr error @@ -52,26 +52,26 @@ func TestOauthTokenSync_SyncOauthToken(t *testing.T) { desc: "should skip sync for when access token don't have expire time", identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, expectHasEntryCalled: true, - expectedHasEntryToken: &models.UserAuth{}, + expectedHasEntryToken: &login.UserAuth{}, }, { desc: "should skip sync when access token has no expired yet", identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, expectHasEntryCalled: true, - expectedHasEntryToken: &models.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, + expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, }, { desc: "should skip sync when access token has no expired yet", identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, expectHasEntryCalled: true, - expectedHasEntryToken: &models.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, + expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, }, { desc: "should refresh access token when is has expired", identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}}, expectHasEntryCalled: true, expectTryRefreshTokenCalled: true, - expectedHasEntryToken: &models.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, + expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, }, { desc: "should invalidate access token and session token if access token can't be refreshed", @@ -81,7 +81,7 @@ func TestOauthTokenSync_SyncOauthToken(t *testing.T) { expectTryRefreshTokenCalled: true, expectInvalidateOauthTokensCalled: true, expectRevokeTokenCalled: true, - expectedHasEntryToken: &models.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, + expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)}, expectedErr: errExpiredAccessToken, }, } @@ -96,15 +96,15 @@ func TestOauthTokenSync_SyncOauthToken(t *testing.T) { ) service := &oauthtokentest.MockOauthTokenService{ - HasOAuthEntryFunc: func(ctx context.Context, usr *user.SignedInUser) (*models.UserAuth, bool, error) { + HasOAuthEntryFunc: func(ctx context.Context, usr *user.SignedInUser) (*login.UserAuth, bool, error) { hasEntryCalled = true return tt.expectedHasEntryToken, tt.expectedHasEntryToken != nil, nil }, - InvalidateOAuthTokensFunc: func(ctx context.Context, usr *models.UserAuth) error { + InvalidateOAuthTokensFunc: func(ctx context.Context, usr *login.UserAuth) error { invalidateTokensCalled = true return nil }, - TryTokenRefreshFunc: func(ctx context.Context, usr *models.UserAuth) error { + TryTokenRefreshFunc: func(ctx context.Context, usr *login.UserAuth) error { tryRefreshCalled = true return tt.expectedTryRefreshErr }, diff --git a/pkg/services/authn/authnimpl/sync/org_sync_test.go b/pkg/services/authn/authnimpl/sync/org_sync_test.go index 4fd87ba7d9a..4c4f6d6ea06 100644 --- a/pkg/services/authn/authnimpl/sync/org_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/org_sync_test.go @@ -4,17 +4,18 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/stretchr/testify/assert" ) func TestOrgSync_SyncOrgUser(t *testing.T) { @@ -79,7 +80,7 @@ func TestOrgSync_SyncOrgUser(t *testing.T) { IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, @@ -97,7 +98,7 @@ func TestOrgSync_SyncOrgUser(t *testing.T) { IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index f1740a33b0b..a054a7032ad 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" @@ -140,7 +139,7 @@ func (s *UserSync) updateAuthInfo(ctx context.Context, id *authn.Identity) error return fmt.Errorf("invalid namespace %q for user ID %q", namespace, userID) } - updateCmd := &models.UpdateAuthInfoCommand{ + updateCmd := &login.UpdateAuthInfoCommand{ AuthModule: id.AuthModule, AuthId: id.AuthID, UserId: userID, @@ -222,7 +221,7 @@ func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.Us } if id.AuthModule != "" && id.AuthID != "" { - if errSetAuth := s.authInfoService.SetAuthInfo(ctx, &models.SetAuthInfoCommand{ + if errSetAuth := s.authInfoService.SetAuthInfo(ctx, &login.SetAuthInfoCommand{ UserId: usr.ID, AuthModule: id.AuthModule, AuthId: id.AuthID, @@ -241,10 +240,10 @@ func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.Us func (s *UserSync) UserInDB(ctx context.Context, authID *string, authModule *string, - params models.UserLookupParams) (*user.User, error) { + params login.UserLookupParams) (*user.User, error) { // Check authinfo table if authID != nil && authModule != nil { - query := &models.GetAuthInfoQuery{ + query := &login.GetAuthInfoQuery{ AuthModule: *authModule, AuthId: *authID, } @@ -269,7 +268,7 @@ func (s *UserSync) UserInDB(ctx context.Context, return s.LookupByOneOf(ctx, ¶ms) } -func (s *UserSync) LookupByOneOf(ctx context.Context, params *models.UserLookupParams) (*user.User, error) { +func (s *UserSync) LookupByOneOf(ctx context.Context, params *login.UserLookupParams) (*user.User, error) { var usr *user.User var err error diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index cb92d1112e2..544419c76a8 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -4,7 +4,8 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/authinfoservice" @@ -13,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/stretchr/testify/require" ) func ptrString(s string) *string { @@ -34,17 +34,17 @@ func TestUserSync_SyncUser(t *testing.T) { authFakeNil := &logintest.AuthInfoServiceFake{ ExpectedUser: nil, ExpectedError: user.ErrUserNotFound, - SetAuthInfoFn: func(ctx context.Context, cmd *models.SetAuthInfoCommand) error { + SetAuthInfoFn: func(ctx context.Context, cmd *login.SetAuthInfoCommand) error { return nil }, - UpdateAuthInfoFn: func(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { + UpdateAuthInfoFn: func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { return nil }, } authFakeUserID := &logintest.AuthInfoServiceFake{ ExpectedUser: nil, ExpectedError: nil, - ExpectedUserAuth: &models.UserAuth{ + ExpectedUserAuth: &login.UserAuth{ AuthModule: "oauth", AuthId: "2032", UserId: 1, @@ -111,7 +111,7 @@ func TestUserSync_SyncUser(t *testing.T) { Name: "test", Email: "test", ClientParams: authn.ClientParams{ - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, @@ -126,7 +126,7 @@ func TestUserSync_SyncUser(t *testing.T) { Name: "test", Email: "test", ClientParams: authn.ClientParams{ - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, @@ -150,7 +150,7 @@ func TestUserSync_SyncUser(t *testing.T) { Email: "test", ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, @@ -167,7 +167,7 @@ func TestUserSync_SyncUser(t *testing.T) { IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test"), Login: nil, @@ -191,7 +191,7 @@ func TestUserSync_SyncUser(t *testing.T) { Email: "test", ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: nil, Login: ptrString("test"), @@ -207,7 +207,7 @@ func TestUserSync_SyncUser(t *testing.T) { Email: "test", IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: nil, Login: ptrString("test"), @@ -232,7 +232,7 @@ func TestUserSync_SyncUser(t *testing.T) { Email: "test", ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: ptrInt64(1), Email: nil, Login: nil, @@ -249,7 +249,7 @@ func TestUserSync_SyncUser(t *testing.T) { IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: ptrInt64(1), Email: nil, Login: nil, @@ -274,7 +274,7 @@ func TestUserSync_SyncUser(t *testing.T) { Email: "test", ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: nil, Login: nil, @@ -291,7 +291,7 @@ func TestUserSync_SyncUser(t *testing.T) { IsGrafanaAdmin: ptrBool(false), ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: nil, Login: nil, @@ -317,7 +317,7 @@ func TestUserSync_SyncUser(t *testing.T) { AuthID: "2032", ClientParams: authn.ClientParams{ SyncUser: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: nil, Login: nil, @@ -348,7 +348,7 @@ func TestUserSync_SyncUser(t *testing.T) { SyncUser: true, AllowSignUp: true, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test_create"), Login: nil, @@ -369,7 +369,7 @@ func TestUserSync_SyncUser(t *testing.T) { SyncUser: true, AllowSignUp: true, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: ptrString("test_create"), Login: nil, @@ -396,7 +396,7 @@ func TestUserSync_SyncUser(t *testing.T) { ClientParams: authn.ClientParams{ SyncUser: true, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: ptrInt64(3), Email: nil, Login: nil, @@ -415,7 +415,7 @@ func TestUserSync_SyncUser(t *testing.T) { ClientParams: authn.ClientParams{ SyncUser: true, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: ptrInt64(3), Email: nil, Login: nil, diff --git a/pkg/services/authn/authntest/fake.go b/pkg/services/authn/authntest/fake.go index 7f64b016294..a048bf6a091 100644 --- a/pkg/services/authn/authntest/fake.go +++ b/pkg/services/authn/authntest/fake.go @@ -10,14 +10,20 @@ type FakeService struct { authn.Service } -var _ authn.Client = new(FakeClient) +var _ authn.ContextAwareClient = new(FakeClient) type FakeClient struct { + ExpectedName string ExpectedErr error ExpectedTest bool + ExpectedPriority uint ExpectedIdentity *authn.Identity } +func (f *FakeClient) Name() string { + return f.ExpectedName +} + func (f *FakeClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { return f.ExpectedIdentity, f.ExpectedErr } @@ -26,6 +32,10 @@ func (f *FakeClient) Test(ctx context.Context, r *authn.Request) bool { return f.ExpectedTest } +func (f *FakeClient) Priority() uint { + return f.ExpectedPriority +} + var _ authn.PasswordClient = new(FakePasswordClient) type FakePasswordClient struct { @@ -42,18 +52,18 @@ var _ authn.RedirectClient = new(FakeRedirectClient) type FakeRedirectClient struct { ExpectedErr error ExpectedURL string - ExpectedOK bool + ExpectedName string ExpectedIdentity *authn.Identity } +func (f FakeRedirectClient) Name() string { + return f.ExpectedName +} + func (f FakeRedirectClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { return f.ExpectedIdentity, f.ExpectedErr } -func (f FakeRedirectClient) Test(ctx context.Context, r *authn.Request) bool { - return f.ExpectedOK -} - func (f FakeRedirectClient) RedirectURL(ctx context.Context, r *authn.Request) (string, error) { return f.ExpectedURL, f.ExpectedErr } diff --git a/pkg/services/authn/authntest/mock.go b/pkg/services/authn/authntest/mock.go index 7bb7f1c92df..98f5ae5c6c2 100644 --- a/pkg/services/authn/authntest/mock.go +++ b/pkg/services/authn/authntest/mock.go @@ -6,11 +6,20 @@ import ( "github.com/grafana/grafana/pkg/services/authn" ) -var _ authn.Client = new(MockClient) +var _ authn.ContextAwareClient = new(MockClient) type MockClient struct { + NameFunc func() string AuthenticateFunc func(ctx context.Context, r *authn.Request) (*authn.Identity, error) TestFunc func(ctx context.Context, r *authn.Request) bool + PriorityFunc func() uint +} + +func (m MockClient) Name() string { + if m.NameFunc != nil { + return m.NameFunc() + } + return "" } func (m MockClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { @@ -27,6 +36,13 @@ func (m MockClient) Test(ctx context.Context, r *authn.Request) bool { return false } +func (m MockClient) Priority() uint { + if m.PriorityFunc != nil { + return m.PriorityFunc() + } + return 0 +} + var _ authn.ProxyClient = new(MockProxyClient) type MockProxyClient struct { diff --git a/pkg/services/authn/clients/anonymous.go b/pkg/services/authn/clients/anonymous.go index e8d6b54c7ec..e37b0ebb394 100644 --- a/pkg/services/authn/clients/anonymous.go +++ b/pkg/services/authn/clients/anonymous.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var _ authn.Client = new(Anonymous) +var _ authn.ContextAwareClient = new(Anonymous) func ProvideAnonymous(cfg *setting.Cfg, orgService org.Service) *Anonymous { return &Anonymous{ @@ -25,6 +25,10 @@ type Anonymous struct { orgService org.Service } +func (a *Anonymous) Name() string { + return authn.ClientAnonymous +} + func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { o, err := a.orgService.GetByName(ctx, &org.GetOrgByNameQuery{Name: a.cfg.AnonymousOrgName}) if err != nil { @@ -33,6 +37,7 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn. } return &authn.Identity{ + IsAnonymous: true, OrgID: o.ID, OrgName: o.Name, OrgRoles: map[int64]org.RoleType{o.ID: org.RoleType(a.cfg.AnonymousOrgRole)}, @@ -44,3 +49,7 @@ func (a *Anonymous) Test(ctx context.Context, r *authn.Request) bool { // If anonymous client is register it can always be used for authentication return true } + +func (a *Anonymous) Priority() uint { + return 100 +} diff --git a/pkg/services/authn/clients/api_key.go b/pkg/services/authn/clients/api_key.go index 958cdf7fb63..6efb52a24ea 100644 --- a/pkg/services/authn/clients/api_key.go +++ b/pkg/services/authn/clients/api_key.go @@ -23,7 +23,7 @@ var ( errAPIKeyRevoked = errutil.NewBase(errutil.StatusUnauthorized, "api-key.revoked", errutil.WithPublicMessage("Revoked API key")) ) -var _ authn.Client = new(APIKey) +var _ authn.ContextAwareClient = new(APIKey) func ProvideAPIKey(apiKeyService apikey.Service, userService user.Service) *APIKey { return &APIKey{ @@ -39,6 +39,10 @@ type APIKey struct { apiKeyService apikey.Service } +func (s *APIKey) Name() string { + return authn.ClientAPIKey +} + func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { apiKey, err := s.getAPIKey(ctx, getTokenFromRequest(r)) if err != nil { @@ -133,6 +137,10 @@ func (s *APIKey) Test(ctx context.Context, r *authn.Request) bool { return looksLikeApiKey(getTokenFromRequest(r)) } +func (s *APIKey) Priority() uint { + return 30 +} + func looksLikeApiKey(token string) bool { return token != "" } diff --git a/pkg/services/authn/clients/basic.go b/pkg/services/authn/clients/basic.go index 4c8c0c529cf..5e37640179e 100644 --- a/pkg/services/authn/clients/basic.go +++ b/pkg/services/authn/clients/basic.go @@ -13,7 +13,7 @@ var ( errDecodingBasicAuthHeader = errutil.NewBase(errutil.StatusBadRequest, "basic-auth.invalid-header", errutil.WithPublicMessage("Invalid Basic Auth Header")) ) -var _ authn.Client = new(Basic) +var _ authn.ContextAwareClient = new(Basic) func ProvideBasic(client authn.PasswordClient) *Basic { return &Basic{client} @@ -23,6 +23,10 @@ type Basic struct { client authn.PasswordClient } +func (c *Basic) Name() string { + return authn.ClientBasic +} + func (c *Basic) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { username, password, err := util.DecodeBasicAuthHeader(getBasicAuthHeaderFromRequest(r)) if err != nil { @@ -36,6 +40,10 @@ func (c *Basic) Test(ctx context.Context, r *authn.Request) bool { return looksLikeBasicAuthRequest(r) } +func (c *Basic) Priority() uint { + return 40 +} + func looksLikeBasicAuthRequest(r *authn.Request) bool { return getBasicAuthHeaderFromRequest(r) != "" } diff --git a/pkg/services/authn/clients/form.go b/pkg/services/authn/clients/form.go index f8e4de8bdb0..2195892cca3 100644 --- a/pkg/services/authn/clients/form.go +++ b/pkg/services/authn/clients/form.go @@ -27,16 +27,14 @@ type loginForm struct { Password string `json:"password" binding:"Required"` } -func (f *Form) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { +func (c *Form) Name() string { + return authn.ClientForm +} + +func (c *Form) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { form := loginForm{} if err := web.Bind(r.HTTPRequest, &form); err != nil { return nil, errBadForm.Errorf("failed to parse request: %w", err) } - return f.client.AuthenticatePassword(ctx, r, form.Username, form.Password) -} - -func (f *Form) Test(ctx context.Context, r *authn.Request) bool { - // FIXME: How should we detect this?? - // Maybe create client test interface and not all clients has to implement this?? - return true + return c.client.AuthenticatePassword(ctx, r, form.Username, form.Password) } diff --git a/pkg/services/authn/clients/grafana_test.go b/pkg/services/authn/clients/grafana_test.go index 918888f5a23..9bf3ce88a13 100644 --- a/pkg/services/authn/clients/grafana_test.go +++ b/pkg/services/authn/clients/grafana_test.go @@ -5,14 +5,15 @@ import ( "net/http" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" - "github.com/stretchr/testify/assert" ) func TestGrafana_AuthenticateProxy(t *testing.T) { @@ -51,7 +52,7 @@ func TestGrafana_AuthenticateProxy(t *testing.T) { SyncUser: true, SyncTeamMembers: true, AllowSignUp: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ Email: strPtr("email@email.com"), Login: strPtr("test"), }, @@ -72,7 +73,7 @@ func TestGrafana_AuthenticateProxy(t *testing.T) { SyncUser: true, SyncTeamMembers: true, AllowSignUp: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ Email: strPtr("test@test.com"), Login: strPtr("test@test.com"), }, diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 6c9b18bb272..8ad47e3105e 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -20,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) -var _ authn.Client = new(JWT) +var _ authn.ContextAwareClient = new(JWT) var ( ErrJWTInvalid = errutil.NewBase(errutil.StatusUnauthorized, @@ -45,6 +45,10 @@ type JWT struct { jwtService auth.JWTVerifierService } +func (s *JWT) Name() string { + return authn.ClientJWT +} + func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { jwtToken := s.retrieveToken(r.HTTPRequest) @@ -65,9 +69,10 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi AuthID: sub, OrgRoles: map[int64]org.RoleType{}, ClientParams: authn.ClientParams{ - SyncUser: true, - SyncTeamMembers: true, - AllowSignUp: false, + SyncUser: true, + // We do not allow team member sync from JWT Authentication + SyncTeamMembers: false, + AllowSignUp: s.cfg.JWTAuthAutoSignUp, EnableDisabledUsers: false, }} @@ -121,10 +126,6 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi return nil, ErrJWTMissingClaim.Errorf("missing login and email claim in JWT") } - if s.cfg.JWTAuthAutoSignUp { - id.ClientParams.AllowSignUp = true - } - return id, nil } @@ -157,6 +158,10 @@ func (s *JWT) Test(ctx context.Context, r *authn.Request) bool { return true } +func (s *JWT) Priority() uint { + return 20 +} + const roleGrafanaAdmin = "GrafanaAdmin" func (s *JWT) extractRoleAndAdmin(claims map[string]interface{}) (org.RoleType, bool) { diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index 80df9289092..29035b19145 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -10,10 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" ) @@ -49,10 +49,10 @@ func TestAuthenticateJWT(t *testing.T) { IsDisabled: false, HelpFlags1: 0, ClientParams: authn.ClientParams{ + SyncTeamMembers: false, SyncUser: true, AllowSignUp: true, - SyncTeamMembers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ UserID: nil, Email: stringPtr("eai.doe@cor.po"), Login: stringPtr("eai-doe"), diff --git a/pkg/services/authn/clients/ldap.go b/pkg/services/authn/clients/ldap.go index 0e8f14a5be0..943601abf42 100644 --- a/pkg/services/authn/clients/ldap.go +++ b/pkg/services/authn/clients/ldap.go @@ -4,8 +4,8 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" "github.com/grafana/grafana/pkg/setting" ) @@ -36,7 +36,7 @@ func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username } func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) { - info, err := c.service.Login(&models.LoginUserQuery{ + info, err := c.service.Login(&login.LoginUserQuery{ Username: username, Password: password, }) @@ -61,8 +61,8 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern } type ldapService interface { - Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) - User(username string) (*models.ExternalUserInfo, error) + Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) + User(username string) (*login.ExternalUserInfo, error) } // FIXME: remove the implementation if we convert ldap to an actual service @@ -70,7 +70,7 @@ type ldapServiceImpl struct { cfg *setting.Cfg } -func (s *ldapServiceImpl) Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) { +func (s *ldapServiceImpl) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) { cfg, err := multildap.GetConfig(s.cfg) if err != nil { return nil, err @@ -79,7 +79,7 @@ func (s *ldapServiceImpl) Login(query *models.LoginUserQuery) (*models.ExternalU return multildap.New(cfg.Servers).Login(query) } -func (s *ldapServiceImpl) User(username string) (*models.ExternalUserInfo, error) { +func (s *ldapServiceImpl) User(username string) (*login.ExternalUserInfo, error) { cfg, err := multildap.GetConfig(s.cfg) if err != nil { return nil, err @@ -89,7 +89,7 @@ func (s *ldapServiceImpl) User(username string) (*models.ExternalUserInfo, error return user, err } -func identityFromLDAPInfo(orgID int64, info *models.ExternalUserInfo, allowSignup bool) *authn.Identity { +func identityFromLDAPInfo(orgID int64, info *login.ExternalUserInfo, allowSignup bool) *authn.Identity { return &authn.Identity{ OrgID: orgID, OrgRoles: info.OrgRoles, @@ -105,7 +105,7 @@ func identityFromLDAPInfo(orgID int64, info *models.ExternalUserInfo, allowSignu SyncTeamMembers: true, AllowSignUp: allowSignup, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ Login: &info.Login, Email: &info.Email, }, diff --git a/pkg/services/authn/clients/ldap_test.go b/pkg/services/authn/clients/ldap_test.go index e59516f2cf1..e5c59207320 100644 --- a/pkg/services/authn/clients/ldap_test.go +++ b/pkg/services/authn/clients/ldap_test.go @@ -4,14 +4,14 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" ) func TestLDAP_AuthenticateProxy(t *testing.T) { @@ -19,7 +19,7 @@ func TestLDAP_AuthenticateProxy(t *testing.T) { desc string username string expectedLDAPErr error - expectedLDAPInfo *models.ExternalUserInfo + expectedLDAPInfo *login.ExternalUserInfo expectedErr error expectedIdentity *authn.Identity } @@ -28,7 +28,7 @@ func TestLDAP_AuthenticateProxy(t *testing.T) { { desc: "should return valid identity when found by ldap service", username: "test", - expectedLDAPInfo: &models.ExternalUserInfo{ + expectedLDAPInfo: &login.ExternalUserInfo{ AuthModule: login.LDAPAuthModule, AuthId: "123", Email: "test@test.com", @@ -51,7 +51,7 @@ func TestLDAP_AuthenticateProxy(t *testing.T) { SyncTeamMembers: true, AllowSignUp: false, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ Email: strPtr("test@test.com"), Login: strPtr("test"), }, @@ -83,7 +83,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) { password string expectedErr error expectedLDAPErr error - expectedLDAPInfo *models.ExternalUserInfo + expectedLDAPInfo *login.ExternalUserInfo expectedIdentity *authn.Identity } @@ -92,7 +92,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) { desc: "should successfully authenticate with correct username and password", username: "test", password: "test123", - expectedLDAPInfo: &models.ExternalUserInfo{ + expectedLDAPInfo: &login.ExternalUserInfo{ AuthModule: login.LDAPAuthModule, AuthId: "123", Email: "test@test.com", @@ -115,7 +115,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) { SyncTeamMembers: true, AllowSignUp: false, EnableDisabledUsers: true, - LookUpParams: models.UserLookupParams{ + LookUpParams: login.UserLookupParams{ Email: strPtr("test@test.com"), Login: strPtr("test"), }, @@ -157,13 +157,13 @@ var _ ldapService = new(fakeLDAPService) type fakeLDAPService struct { ExpectedErr error - ExpectedInfo *models.ExternalUserInfo + ExpectedInfo *login.ExternalUserInfo } -func (f fakeLDAPService) Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) { +func (f fakeLDAPService) Login(query *login.LoginUserQuery) (*login.ExternalUserInfo, error) { return f.ExpectedInfo, f.ExpectedErr } -func (f fakeLDAPService) User(username string) (*models.ExternalUserInfo, error) { +func (f fakeLDAPService) User(username string) (*login.ExternalUserInfo, error) { return f.ExpectedInfo, f.ExpectedErr } diff --git a/pkg/services/authn/clients/proxy.go b/pkg/services/authn/clients/proxy.go index 1f8a7d0ad69..c608df4d859 100644 --- a/pkg/services/authn/clients/proxy.go +++ b/pkg/services/authn/clients/proxy.go @@ -29,7 +29,7 @@ var ( errInvalidProxyHeader = errutil.NewBase(errutil.StatusInternal, "auth-proxy.invalid-proxy-header") ) -var _ authn.Client = new(Proxy) +var _ authn.ContextAwareClient = new(Proxy) func ProvideProxy(cfg *setting.Cfg, clients ...authn.ProxyClient) (*Proxy, error) { list, err := parseAcceptList(cfg.AuthProxyWhitelist) @@ -45,6 +45,10 @@ type Proxy struct { acceptedIPs []*net.IPNet } +func (c *Proxy) Name() string { + return authn.ClientProxy +} + func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { if !c.isAllowedIP(r) { return nil, errNotAcceptedIP.Errorf("request ip is not in the configured accept list") @@ -75,6 +79,10 @@ func (c *Proxy) Test(ctx context.Context, r *authn.Request) bool { return len(getProxyHeader(r, c.cfg.AuthProxyHeaderName, c.cfg.AuthProxyHeadersEncoded)) != 0 } +func (c *Proxy) Priority() uint { + return 50 +} + func (c *Proxy) isAllowedIP(r *authn.Request) bool { if len(c.acceptedIPs) == 0 { return true diff --git a/pkg/services/authn/clients/render.go b/pkg/services/authn/clients/render.go index 86dd8e21850..44df3ecc4ed 100644 --- a/pkg/services/authn/clients/render.go +++ b/pkg/services/authn/clients/render.go @@ -2,8 +2,10 @@ package clients import ( "context" + "time" "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/user" @@ -18,7 +20,7 @@ const ( renderCookieName = "renderKey" ) -var _ authn.Client = new(Render) +var _ authn.ContextAwareClient = new(Render) func ProvideRender(userService user.Service, renderService rendering.Service) *Render { return &Render{userService, renderService} @@ -29,6 +31,10 @@ type Render struct { renderService rendering.Service } +func (c *Render) Name() string { + return authn.ClientRender +} + func (c *Render) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { key := getRenderKey(r) renderUsr, ok := c.renderService.GetRenderUser(ctx, key) @@ -36,20 +42,25 @@ func (c *Render) Authenticate(ctx context.Context, r *authn.Request) (*authn.Ide return nil, ErrInvalidRenderKey.Errorf("found no render user for key: %s", key) } + var identity *authn.Identity if renderUsr.UserID <= 0 { - return &authn.Identity{ - ID: authn.NamespacedID(authn.NamespaceUser, 0), - OrgID: renderUsr.OrgID, - OrgRoles: map[int64]org.RoleType{renderUsr.OrgID: org.RoleType(renderUsr.OrgRole)}, - ClientParams: authn.ClientParams{}, - }, nil + identity = &authn.Identity{ + ID: authn.NamespacedID(authn.NamespaceUser, 0), + OrgID: renderUsr.OrgID, + OrgRoles: map[int64]org.RoleType{renderUsr.OrgID: org.RoleType(renderUsr.OrgRole)}, + } + } else { + usr, err := c.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{UserID: renderUsr.UserID, OrgID: renderUsr.OrgID}) + if err != nil { + return nil, err + } + + identity = authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, usr.UserID), usr, authn.ClientParams{}) } - usr, err := c.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{UserID: renderUsr.UserID, OrgID: renderUsr.OrgID}) - if err != nil { - return nil, err - } - return authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, usr.UserID), usr, authn.ClientParams{}), nil + identity.LastSeenAt = time.Now() + identity.AuthModule = login.RenderModule + return identity, nil } func (c *Render) Test(ctx context.Context, r *authn.Request) bool { @@ -59,6 +70,10 @@ func (c *Render) Test(ctx context.Context, r *authn.Request) bool { return getRenderKey(r) != "" } +func (c *Render) Priority() uint { + return 10 +} + func getRenderKey(r *authn.Request) string { cookie, err := r.HTTPRequest.Cookie(renderCookieName) if err != nil { diff --git a/pkg/services/authn/clients/render_test.go b/pkg/services/authn/clients/render_test.go index 63b283dadf0..5a69914b05b 100644 --- a/pkg/services/authn/clients/render_test.go +++ b/pkg/services/authn/clients/render_test.go @@ -4,14 +4,17 @@ import ( "context" "net/http" "testing" + "time" "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/stretchr/testify/assert" ) func TestRender_Authenticate(t *testing.T) { @@ -35,9 +38,10 @@ func TestRender_Authenticate(t *testing.T) { }, }, expectedIdentity: &authn.Identity{ - ID: "user:0", - OrgID: 1, - OrgRoles: map[int64]org.RoleType{1: org.RoleViewer}, + ID: "user:0", + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleViewer}, + AuthModule: login.RenderModule, }, expectedRenderUsr: &rendering.RenderUser{ OrgID: 1, @@ -59,6 +63,7 @@ func TestRender_Authenticate(t *testing.T) { OrgName: "test", OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, IsGrafanaAdmin: boolPtr(false), + AuthModule: login.RenderModule, }, expectedRenderUsr: &rendering.RenderUser{ OrgID: 1, @@ -97,6 +102,8 @@ func TestRender_Authenticate(t *testing.T) { assert.Nil(t, identity) } else { assert.NoError(t, err) + // ignore LastSeenAt + identity.LastSeenAt = time.Time{} assert.EqualValues(t, *tt.expectedIdentity, *identity) } }) diff --git a/pkg/services/authn/clients/session.go b/pkg/services/authn/clients/session.go index 1b80078114c..8c26fb60332 100644 --- a/pkg/services/authn/clients/session.go +++ b/pkg/services/authn/clients/session.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -var _ authn.Client = new(Session) +var _ authn.ContextAwareClient = new(Session) func ProvideSession(sessionService auth.UserTokenService, userService user.Service, cookieName string, maxLifetime time.Duration) *Session { @@ -36,16 +36,8 @@ type Session struct { log log.Logger } -func (s *Session) Test(ctx context.Context, r *authn.Request) bool { - if s.loginCookieName == "" { - return false - } - - if _, err := r.HTTPRequest.Cookie(s.loginCookieName); err != nil { - return false - } - - return true +func (s *Session) Name() string { + return authn.ClientSession } func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { @@ -79,6 +71,22 @@ func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id return identity, nil } +func (s *Session) Test(ctx context.Context, r *authn.Request) bool { + if s.loginCookieName == "" { + return false + } + + if _, err := r.HTTPRequest.Cookie(s.loginCookieName); err != nil { + return false + } + + return true +} + +func (s *Session) Priority() uint { + return 60 +} + func (s *Session) RefreshTokenHook(ctx context.Context, identity *authn.Identity, r *authn.Request) error { if identity.SessionToken == nil { return nil diff --git a/pkg/services/comments/commentmodel/permissions.go b/pkg/services/comments/commentmodel/permissions.go index c11f54179e4..19803966939 100644 --- a/pkg/services/comments/commentmodel/permissions.go +++ b/pkg/services/comments/commentmodel/permissions.go @@ -30,18 +30,20 @@ func NewPermissionChecker(sqlStore db.DB, features featuremgmt.FeatureToggles, func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { query := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID} - if err := c.dashboardService.GetDashboard(ctx, &query); err != nil { + queryResult, err := c.dashboardService.GetDashboard(ctx, &query) + if err != nil { return nil, err } - return query.Result, nil + return queryResult, nil } func (c *PermissionChecker) getDashboardById(ctx context.Context, orgID int64, id int64) (*dashboards.Dashboard, error) { query := dashboards.GetDashboardQuery{ID: id, OrgID: orgID} - if err := c.dashboardService.GetDashboard(ctx, &query); err != nil { + queryResult, err := c.dashboardService.GetDashboard(ctx, &query) + if err != nil { return nil, err } - return query.Result, nil + return queryResult, nil } func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int64, signedInUser *user.SignedInUser, objectType string, objectID string) (bool, error) { diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index 4386471c12d..a0a5b9750a9 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -9,11 +9,10 @@ import ( "github.com/jmespath/go-jmespath" "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models/roletype" authJWT "github.com/grafana/grafana/pkg/services/auth/jwt" - "github.com/grafana/grafana/pkg/services/authn" - "github.com/grafana/grafana/pkg/services/featuremgmt" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + loginsvc "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" ) @@ -24,28 +23,7 @@ const ( UserNotFound = "User not found" ) -func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(ctx.Req.Context(), - authn.ClientJWT, - &authn.Request{HTTPRequest: ctx.Req, Resp: ctx.Resp, OrgID: orgId}) - if !ok { - return false - } - - newCtx := WithAuthHTTPHeader(ctx.Req.Context(), h.Cfg.JWTAuthHeaderName) - *ctx.Req = *ctx.Req.WithContext(newCtx) - - if err != nil { - ctx.WriteErr(err) - return true - } - - ctx.SignedInUser = identity.SignedInUser() - ctx.IsSignedIn = true - return true - } - +func (h *ContextHandler) initContextWithJWT(ctx *contextmodel.ReqContext, orgId int64) bool { if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" { return false } @@ -83,10 +61,12 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) ctx.JsonApiErr(http.StatusUnauthorized, InvalidJWT, err) return true } - extUser := &models.ExternalUserInfo{ + extUser := &loginsvc.ExternalUserInfo{ AuthModule: "jwt", AuthId: sub, OrgRoles: map[int64]org.RoleType{}, + // we do not want to sync team memberships from JWT authentication see - https://github.com/grafana/grafana/issues/62175 + SkipTeamSync: true, } if key := h.Cfg.JWTAuthUsernameClaim; key != "" { @@ -137,11 +117,11 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) } if h.Cfg.JWTAuthAutoSignUp { - upsert := &models.UpsertUserCommand{ + upsert := &loginsvc.UpsertUserCommand{ ReqContext: ctx, SignupAllowed: h.Cfg.JWTAuthAutoSignUp, ExternalUser: extUser, - UserLookupParams: models.UserLookupParams{ + UserLookupParams: loginsvc.UserLookupParams{ UserID: nil, Login: &query.Login, Email: &query.Email, diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 2f409ff256d..773e6b36ee0 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -12,12 +12,13 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "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/authtest" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/rendering" @@ -42,7 +43,7 @@ func TestInitContextWithAuthProxy_CachedInvalidUserID(t *testing.T) { req, err := http.NewRequest("POST", "http://example.com", nil) require.NoError(t, err) - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, Logger: log.New("Test"), } @@ -113,6 +114,6 @@ func getContextHandler(t *testing.T) *ContextHandler { type fakeAuthenticator struct{} -func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { +func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *login.LoginUserQuery) error { return nil } diff --git a/pkg/services/contexthandler/authproxy/authproxy.go b/pkg/services/contexthandler/authproxy/authproxy.go index 73c9523d50a..6a6fbda21b7 100644 --- a/pkg/services/contexthandler/authproxy/authproxy.go +++ b/pkg/services/contexthandler/authproxy/authproxy.go @@ -16,7 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" @@ -98,7 +98,7 @@ func (auth *AuthProxy) IsEnabled() bool { } // HasHeader checks if we have specified header -func (auth *AuthProxy) HasHeader(reqCtx *models.ReqContext) bool { +func (auth *AuthProxy) HasHeader(reqCtx *contextmodel.ReqContext) bool { header := auth.getDecodedHeader(reqCtx, auth.cfg.AuthProxyHeaderName) return len(header) != 0 } @@ -149,7 +149,7 @@ func HashCacheKey(key string) (string, error) { // getKey forms a key for the cache based on the headers received as part of the authentication flow. // Our configuration supports multiple headers. The main header contains the email or username. // And the additional ones that allow us to specify extra attributes: Name, Email, Role, or Groups. -func (auth *AuthProxy) getKey(reqCtx *models.ReqContext) (string, error) { +func (auth *AuthProxy) getKey(reqCtx *contextmodel.ReqContext) (string, error) { header := auth.getDecodedHeader(reqCtx, auth.cfg.AuthProxyHeaderName) key := strings.TrimSpace(header) // start the key with the main header @@ -165,7 +165,7 @@ func (auth *AuthProxy) getKey(reqCtx *models.ReqContext) (string, error) { } // Login logs in user ID by whatever means possible. -func (auth *AuthProxy) Login(reqCtx *models.ReqContext, ignoreCache bool) (int64, error) { +func (auth *AuthProxy) Login(reqCtx *contextmodel.ReqContext, ignoreCache bool) (int64, error) { if !ignoreCache { // Error here means absent cache - we don't need to handle that id, err := auth.getUserViaCache(reqCtx) @@ -195,7 +195,7 @@ func (auth *AuthProxy) Login(reqCtx *models.ReqContext, ignoreCache bool) (int64 } // getUserViaCache gets user ID from cache. -func (auth *AuthProxy) getUserViaCache(reqCtx *models.ReqContext) (int64, error) { +func (auth *AuthProxy) getUserViaCache(reqCtx *contextmodel.ReqContext) (int64, error) { cacheKey, err := auth.getKey(reqCtx) if err != nil { return 0, err @@ -212,7 +212,7 @@ func (auth *AuthProxy) getUserViaCache(reqCtx *models.ReqContext) (int64, error) } // RemoveUserFromCache removes user from cache. -func (auth *AuthProxy) RemoveUserFromCache(reqCtx *models.ReqContext) error { +func (auth *AuthProxy) RemoveUserFromCache(reqCtx *contextmodel.ReqContext) error { cacheKey, err := auth.getKey(reqCtx) if err != nil { return err @@ -227,7 +227,7 @@ func (auth *AuthProxy) RemoveUserFromCache(reqCtx *models.ReqContext) error { } // LoginViaLDAP logs in user via LDAP request -func (auth *AuthProxy) LoginViaLDAP(reqCtx *models.ReqContext) (int64, error) { +func (auth *AuthProxy) LoginViaLDAP(reqCtx *contextmodel.ReqContext) (int64, error) { config, err := getLDAPConfig(auth.cfg) if err != nil { return 0, newError("failed to get LDAP config", err) @@ -241,11 +241,11 @@ func (auth *AuthProxy) LoginViaLDAP(reqCtx *models.ReqContext) (int64, error) { } // Have to sync grafana and LDAP user during log in - upsert := &models.UpsertUserCommand{ + upsert := &login.UpsertUserCommand{ ReqContext: reqCtx, SignupAllowed: auth.cfg.LDAPAllowSignup, ExternalUser: extUser, - UserLookupParams: models.UserLookupParams{ + UserLookupParams: login.UserLookupParams{ Login: &extUser.Login, Email: &extUser.Email, UserID: nil, @@ -259,9 +259,9 @@ func (auth *AuthProxy) LoginViaLDAP(reqCtx *models.ReqContext) (int64, error) { } // loginViaHeader logs in user from the header only -func (auth *AuthProxy) loginViaHeader(reqCtx *models.ReqContext) (int64, error) { +func (auth *AuthProxy) loginViaHeader(reqCtx *contextmodel.ReqContext) (int64, error) { header := auth.getDecodedHeader(reqCtx, auth.cfg.AuthProxyHeaderName) - extUser := &models.ExternalUserInfo{ + extUser := &login.ExternalUserInfo{ AuthModule: login.AuthProxyAuthModule, AuthId: header, } @@ -303,11 +303,11 @@ func (auth *AuthProxy) loginViaHeader(reqCtx *models.ReqContext) (int64, error) } }) - upsert := &models.UpsertUserCommand{ + upsert := &login.UpsertUserCommand{ ReqContext: reqCtx, SignupAllowed: auth.cfg.AuthProxyAutoSignUp, ExternalUser: extUser, - UserLookupParams: models.UserLookupParams{ + UserLookupParams: login.UserLookupParams{ UserID: nil, Login: &extUser.Login, Email: &extUser.Email, @@ -323,7 +323,7 @@ func (auth *AuthProxy) loginViaHeader(reqCtx *models.ReqContext) (int64, error) } // getDecodedHeader gets decoded value of a header with given headerName -func (auth *AuthProxy) getDecodedHeader(reqCtx *models.ReqContext, headerName string) string { +func (auth *AuthProxy) getDecodedHeader(reqCtx *contextmodel.ReqContext, headerName string) string { headerValue := reqCtx.Req.Header.Get(headerName) if auth.cfg.AuthProxyHeadersEncoded { @@ -334,7 +334,7 @@ func (auth *AuthProxy) getDecodedHeader(reqCtx *models.ReqContext, headerName st } // headersIterator iterates over all non-empty supported additional headers -func (auth *AuthProxy) headersIterator(reqCtx *models.ReqContext, fn func(field string, header string)) { +func (auth *AuthProxy) headersIterator(reqCtx *contextmodel.ReqContext, fn func(field string, header string)) { for _, field := range supportedHeaderFields { h := auth.cfg.AuthProxyHeaders[field] if h == "" { @@ -356,7 +356,7 @@ func (auth *AuthProxy) GetSignedInUser(userID int64, orgID int64) (*user.SignedI } // Remember user in cache -func (auth *AuthProxy) Remember(reqCtx *models.ReqContext, id int64) error { +func (auth *AuthProxy) Remember(reqCtx *contextmodel.ReqContext, id int64) error { key, err := auth.getKey(reqCtx) if err != nil { return err diff --git a/pkg/services/contexthandler/authproxy/authproxy_test.go b/pkg/services/contexthandler/authproxy/authproxy_test.go index 736776cc94d..a646a01e68f 100644 --- a/pkg/services/contexthandler/authproxy/authproxy_test.go +++ b/pkg/services/contexthandler/authproxy/authproxy_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/multildap" @@ -23,7 +23,7 @@ import ( const hdrName = "markelog" const id int64 = 42 -func prepareMiddleware(t *testing.T, remoteCache *remotecache.RemoteCache, configureReq func(*http.Request, *setting.Cfg)) (*AuthProxy, *models.ReqContext) { +func prepareMiddleware(t *testing.T, remoteCache *remotecache.RemoteCache, configureReq func(*http.Request, *setting.Cfg)) (*AuthProxy, *contextmodel.ReqContext) { t.Helper() req, err := http.NewRequest("POST", "http://example.com", nil) @@ -38,7 +38,7 @@ func prepareMiddleware(t *testing.T, remoteCache *remotecache.RemoteCache, confi req.Header.Set(cfg.AuthProxyHeaderName, hdrName) } - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, } diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index d2b306b3c99..14f3398ec85 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -20,13 +20,13 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" loginpkg "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/apikey" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken" @@ -99,8 +99,8 @@ type ContextHandler struct { type reqContextKey = ctxkey.Key // FromContext returns the ReqContext value stored in a context.Context, if any. -func FromContext(c context.Context) *models.ReqContext { - if reqCtx, ok := c.Value(reqContextKey{}).(*models.ReqContext); ok { +func FromContext(c context.Context) *contextmodel.ReqContext { + if reqCtx, ok := c.Value(reqContextKey{}).(*contextmodel.ReqContext); ok { return reqCtx } return nil @@ -114,7 +114,7 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { _, span := h.tracer.Start(ctx, "Auth - Middleware") defer span.End() - reqContext := &models.ReqContext{ + reqContext := &contextmodel.ReqContext{ Context: mContext, SignedInUser: &user.SignedInUser{}, IsSignedIn: false, @@ -131,44 +131,62 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { reqContext.Logger = reqContext.Logger.New("traceID", traceID) } - const headerName = "X-Grafana-Org-Id" - orgID := int64(0) - orgIDHeader := reqContext.Req.Header.Get(headerName) - if orgIDHeader != "" { - id, err := strconv.ParseInt(orgIDHeader, 10, 64) - if err == nil { - orgID = id + if h.features.IsEnabled(featuremgmt.FlagAuthnService) { + identity, err := h.authnService.Authenticate(ctx, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) + if err != nil { + if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) { + // Burn the cookie in case of invalid, expired or missing token + reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + } + // Hack: set all errors on LookupTokenErr, so we can check it in auth middlewares + reqContext.LookupTokenErr = err } else { - reqContext.Logger.Debug("Received invalid header", "header", headerName, "value", orgIDHeader) + reqContext.IsSignedIn = true + reqContext.UserToken = identity.SessionToken + reqContext.SignedInUser = identity.SignedInUser() + reqContext.AllowAnonymous = identity.IsAnonymous + reqContext.IsRenderCall = identity.AuthModule == login.RenderModule + // FIXME (kallep): Add auth headers used to context } - } - - queryParameters, err := url.ParseQuery(reqContext.Req.URL.RawQuery) - if err != nil { - reqContext.Logger.Error("Failed to parse query parameters", "error", err) - } - if queryParameters.Has("targetOrgId") { - targetOrg, err := strconv.ParseInt(queryParameters.Get("targetOrgId"), 10, 64) - if err == nil { - orgID = targetOrg - } else { - reqContext.Logger.Error("Invalid target organization ID", "error", err) + } else { + const headerName = "X-Grafana-Org-Id" + orgID := int64(0) + orgIDHeader := reqContext.Req.Header.Get(headerName) + if orgIDHeader != "" { + id, err := strconv.ParseInt(orgIDHeader, 10, 64) + if err == nil { + orgID = id + } else { + reqContext.Logger.Debug("Received invalid header", "header", headerName, "value", orgIDHeader) + } } - } - // the order in which these are tested are important - // look for api key in Authorization header first - // then init session and look for userId in session - // then look for api key in session (special case for render calls via api) - // then test if anonymous access is enabled - switch { - case h.initContextWithRenderAuth(reqContext): - case h.initContextWithJWT(reqContext, orgID): - case h.initContextWithAPIKey(reqContext): - case h.initContextWithBasicAuth(reqContext, orgID): - case h.initContextWithAuthProxy(reqContext, orgID): - case h.initContextWithToken(reqContext, orgID): - case h.initContextWithAnonymousUser(reqContext): + queryParameters, err := url.ParseQuery(reqContext.Req.URL.RawQuery) + if err != nil { + reqContext.Logger.Error("Failed to parse query parameters", "error", err) + } + if queryParameters.Has("targetOrgId") { + targetOrg, err := strconv.ParseInt(queryParameters.Get("targetOrgId"), 10, 64) + if err == nil { + orgID = targetOrg + } else { + reqContext.Logger.Error("Invalid target organization ID", "error", err) + } + } + // the order in which these are tested are important + // look for api key in Authorization header first + // then init session and look for userId in session + // then look for api key in session (special case for render calls via api) + // then test if anonymous access is enabled + switch { + case h.initContextWithRenderAuth(reqContext): + case h.initContextWithJWT(reqContext, orgID): + case h.initContextWithAPIKey(reqContext): + case h.initContextWithBasicAuth(reqContext, orgID): + case h.initContextWithAuthProxy(reqContext, orgID): + case h.initContextWithToken(reqContext, orgID): + case h.initContextWithAnonymousUser(reqContext): + } } reqContext.Logger = reqContext.Logger.New("userId", reqContext.UserID, "orgId", reqContext.OrgID, "uname", reqContext.Login) @@ -200,21 +218,10 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { }) } -func (h *ContextHandler) initContextWithAnonymousUser(reqContext *models.ReqContext) bool { - ctx, span := h.tracer.Start(reqContext.Req.Context(), "initContextWithAnonymousUser") +func (h *ContextHandler) initContextWithAnonymousUser(reqContext *contextmodel.ReqContext) bool { + _, span := h.tracer.Start(reqContext.Req.Context(), "initContextWithAnonymousUser") defer span.End() - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(ctx, authn.ClientAnonymous, &authn.Request{HTTPRequest: reqContext.Req}) - if !ok || err != nil { - return false - } - reqContext.SignedInUser = identity.SignedInUser() - reqContext.IsSignedIn = false - reqContext.AllowAnonymous = true - return true - } - if !h.Cfg.AnonymousEnabled { return false } @@ -275,27 +282,7 @@ func (h *ContextHandler) getAPIKey(ctx context.Context, keyString string) (*apik return keyQuery.Result, nil } -func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientAPIKey, &authn.Request{HTTPRequest: reqContext.Req}) - if !ok { - return false - } - - // include auth header in context - ctx := WithAuthHTTPHeader(reqContext.Req.Context(), "Authorization") - *reqContext.Req = *reqContext.Req.WithContext(ctx) - - if err != nil { - reqContext.WriteErr(err) - return true - } - - reqContext.IsSignedIn = true - reqContext.SignedInUser = identity.SignedInUser() - return true - } - +func (h *ContextHandler) initContextWithAPIKey(reqContext *contextmodel.ReqContext) bool { header := reqContext.Req.Header.Get("Authorization") parts := strings.SplitN(header, " ", 2) var keyString string @@ -319,13 +306,13 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo *reqContext.Req = *reqContext.Req.WithContext(ctx) var ( - apikey *apikey.APIKey + apiKey *apikey.APIKey errKey error ) if strings.HasPrefix(keyString, apikeygenprefix.GrafanaPrefix) { - apikey, errKey = h.getPrefixedAPIKey(reqContext.Req.Context(), keyString) // decode prefixed key + apiKey, errKey = h.getPrefixedAPIKey(reqContext.Req.Context(), keyString) // decode prefixed key } else { - apikey, errKey = h.getAPIKey(reqContext.Req.Context(), keyString) // decode legacy api key + apiKey, errKey = h.getAPIKey(reqContext.Req.Context(), keyString) // decode legacy api key } if errKey != nil { @@ -333,6 +320,11 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo if errors.Is(errKey, apikeygen.ErrInvalidApiKey) { status = http.StatusUnauthorized } + // this is when the getPrefixAPIKey return error form the apikey package instead of the apikeygen + // when called in the sqlx store methods + if errors.Is(errKey, apikey.ErrInvalid) { + status = http.StatusUnauthorized + } reqContext.JsonApiErr(status, InvalidAPIKey, errKey) return true } @@ -342,12 +334,12 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo if getTime == nil { getTime = time.Now } - if apikey.Expires != nil && *apikey.Expires <= getTime().Unix() { + if apiKey.Expires != nil && *apiKey.Expires <= getTime().Unix() { reqContext.JsonApiErr(http.StatusUnauthorized, "Expired API key", nil) return true } - if apikey.IsRevoked != nil && *apikey.IsRevoked { + if apiKey.IsRevoked != nil && *apiKey.IsRevoked { reqContext.JsonApiErr(http.StatusUnauthorized, "Revoked token", nil) return true @@ -363,15 +355,15 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo if err := h.apiKeyService.UpdateAPIKeyLastUsedDate(context.Background(), id); err != nil { reqContext.Logger.Warn("failed to update last use date for api key", "id", id) } - }(apikey.Id) + }(apiKey.Id) - if apikey.ServiceAccountId == nil || *apikey.ServiceAccountId < 1 { //There is no service account attached to the apikey + if apiKey.ServiceAccountId == nil || *apiKey.ServiceAccountId < 1 { //There is no service account attached to the apikey // Use the old APIkey method. This provides backwards compatibility. // will probably have to be supported for a long time. reqContext.SignedInUser = &user.SignedInUser{} - reqContext.OrgRole = apikey.Role - reqContext.ApiKeyID = apikey.Id - reqContext.OrgID = apikey.OrgId + reqContext.OrgRole = apiKey.Role + reqContext.ApiKeyID = apiKey.Id + reqContext.OrgID = apiKey.OrgId reqContext.IsSignedIn = true return true } @@ -379,7 +371,7 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo //There is a service account attached to the API key //Use service account linked to API key as the signed in user - querySignedInUser := user.GetSignedInUserQuery{UserID: *apikey.ServiceAccountId, OrgID: apikey.OrgId} + querySignedInUser := user.GetSignedInUserQuery{UserID: *apiKey.ServiceAccountId, OrgID: apiKey.OrgId} querySignedInUserResult, err := h.userService.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &querySignedInUser) if err != nil { reqContext.Logger.Error( @@ -404,27 +396,7 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo return true } -func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, orgID int64) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientBasic, &authn.Request{HTTPRequest: reqContext.Req}) - if !ok { - return false - } - - // include auth header in context - ctx := WithAuthHTTPHeader(reqContext.Req.Context(), "Authorization") - *reqContext.Req = *reqContext.Req.WithContext(ctx) - - if err != nil { - reqContext.WriteErr(err) - return true - } - - reqContext.IsSignedIn = true - reqContext.SignedInUser = identity.SignedInUser() - return true - } - +func (h *ContextHandler) initContextWithBasicAuth(reqContext *contextmodel.ReqContext, orgID int64) bool { if !h.Cfg.BasicAuthEnabled { return false } @@ -446,7 +418,7 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, ctx := WithAuthHTTPHeader(reqContext.Req.Context(), "Authorization") *reqContext.Req = *reqContext.Req.WithContext(ctx) - authQuery := models.LoginUserQuery{ + authQuery := login.LoginUserQuery{ Username: username, Password: password, Cfg: h.Cfg, @@ -484,30 +456,7 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, return true } -func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, orgID int64) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), - authn.ClientSession, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) - if !ok { - return false - } - - if err != nil { - if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) { - // Burn the cookie in case of invalid, expired or missing token - reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) - } - - reqContext.LookupTokenErr = err - return false - } - - reqContext.IsSignedIn = true - reqContext.SignedInUser = identity.SignedInUser() - reqContext.UserToken = identity.SessionToken - return true - } - +func (h *ContextHandler) initContextWithToken(reqContext *contextmodel.ReqContext, orgID int64) bool { if h.Cfg.LoginCookieName == "" { return false } @@ -579,7 +528,7 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org return true } -func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *models.ReqContext) web.BeforeFunc { +func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *contextmodel.ReqContext) web.BeforeFunc { return func(w web.ResponseWriter) { if w.Written() { reqContext.Logger.Debug("Response written, skipping invalid cookie delete") @@ -591,7 +540,7 @@ func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *models. } } -func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext) web.BeforeFunc { +func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *contextmodel.ReqContext) web.BeforeFunc { return func(w web.ResponseWriter) { // if response has already been written, skip. if w.Written() { @@ -632,25 +581,7 @@ func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext) w } } -func (h *ContextHandler) initContextWithRenderAuth(reqContext *models.ReqContext) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientRender, &authn.Request{HTTPRequest: reqContext.Req}) - if !ok { - return false - } - - if err != nil { - reqContext.WriteErr(err) - return true - } - - reqContext.IsSignedIn = true - reqContext.IsRenderCall = true - reqContext.LastSeenAt = time.Now() - reqContext.SignedInUser = identity.SignedInUser() - return true - } - +func (h *ContextHandler) initContextWithRenderAuth(reqContext *contextmodel.ReqContext) bool { key := reqContext.GetCookie("renderKey") if key == "" { return false @@ -686,7 +617,7 @@ func (h *ContextHandler) initContextWithRenderAuth(reqContext *models.ReqContext return true } -func logUserIn(reqContext *models.ReqContext, auth *authproxy.AuthProxy, username string, logger log.Logger, ignoreCache bool) (int64, error) { +func logUserIn(reqContext *contextmodel.ReqContext, auth *authproxy.AuthProxy, username string, logger log.Logger, ignoreCache bool) (int64, error) { logger.Debug("Trying to log user in", "username", username, "ignoreCache", ignoreCache) // Try to log in user via various providers id, err := auth.Login(reqContext, ignoreCache) @@ -703,7 +634,7 @@ func logUserIn(reqContext *models.ReqContext, auth *authproxy.AuthProxy, usernam return id, nil } -func (h *ContextHandler) handleError(ctx *models.ReqContext, err error, statusCode int, cb func(error)) { +func (h *ContextHandler) handleError(ctx *contextmodel.ReqContext, err error, statusCode int, cb func(error)) { details := err var e authproxy.Error if errors.As(err, &e) { @@ -716,30 +647,7 @@ func (h *ContextHandler) handleError(ctx *models.ReqContext, err error, statusCo } } -func (h *ContextHandler) initContextWithAuthProxy(reqContext *models.ReqContext, orgID int64) bool { - if h.features.IsEnabled(featuremgmt.FlagAuthnService) { - identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientProxy, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp}) - if !ok { - return false - } - - if err != nil { - reqContext.WriteErr(err) - return true - } - - ctx := WithAuthHTTPHeader(reqContext.Req.Context(), h.Cfg.AuthProxyHeaderName) - for _, header := range h.Cfg.AuthProxyHeaders { - if header != "" { - ctx = WithAuthHTTPHeader(ctx, header) - } - } - - *reqContext.Req = *reqContext.Req.WithContext(ctx) - reqContext.IsSignedIn = true - reqContext.SignedInUser = identity.SignedInUser() - return true - } +func (h *ContextHandler) initContextWithAuthProxy(reqContext *contextmodel.ReqContext, orgID int64) bool { username := reqContext.Req.Header.Get(h.Cfg.AuthProxyHeaderName) logger := log.New("auth.proxy") @@ -865,7 +773,7 @@ func AuthHTTPHeaderListFromContext(c context.Context) *AuthHTTPHeaderList { return nil } -func (h *ContextHandler) hasAccessTokenExpired(token *models.UserAuth) bool { +func (h *ContextHandler) hasAccessTokenExpired(token *login.UserAuth) bool { if token.OAuthExpiry.IsZero() { return false } diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index a86329cd2ca..870222a6884 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -12,9 +12,9 @@ import ( "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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -77,7 +77,7 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { } func initTokenRotationScenario(ctx context.Context, t *testing.T, ctxHdlr *ContextHandler) ( - *models.ReqContext, *httptest.ResponseRecorder, error) { + *contextmodel.ReqContext, *httptest.ResponseRecorder, error) { t.Helper() ctxHdlr.Cfg.LoginCookieName = "login_token" @@ -92,7 +92,7 @@ func initTokenRotationScenario(ctx context.Context, t *testing.T, ctxHdlr *Conte if err != nil { return nil, nil, err } - reqContext := &models.ReqContext{ + reqContext := &contextmodel.ReqContext{ Context: &web.Context{Req: req}, Logger: log.New("testlogger"), } diff --git a/pkg/models/context.go b/pkg/services/contexthandler/model/model.go similarity index 98% rename from pkg/models/context.go rename to pkg/services/contexthandler/model/model.go index c6099f423cc..0ae7d23c3b9 100644 --- a/pkg/models/context.go +++ b/pkg/services/contexthandler/model/model.go @@ -1,4 +1,4 @@ -package models +package contextmodel import ( "errors" @@ -110,8 +110,8 @@ func (ctx *ReqContext) writeErrOrFallback(status int, message string, err error) var logMessage string logger := ctx.Logger.Warn - var gfErr *errutil.Error - if errors.As(err, gfErr) { + gfErr := errutil.Error{} + if errors.As(err, &gfErr) { logger = gfErr.LogLevel.LogFunc(ctx.Logger) publicErr := gfErr.Public() diff --git a/pkg/models/context_test.go b/pkg/services/contexthandler/model/model_test.go similarity index 97% rename from pkg/models/context_test.go rename to pkg/services/contexthandler/model/model_test.go index 37b5b58cc5f..aa492d30999 100644 --- a/pkg/models/context_test.go +++ b/pkg/services/contexthandler/model/model_test.go @@ -1,4 +1,4 @@ -package models +package contextmodel import ( "net/http" diff --git a/pkg/services/correlations/api.go b/pkg/services/correlations/api.go index aebba66f847..773236a6eb6 100644 --- a/pkg/services/correlations/api.go +++ b/pkg/services/correlations/api.go @@ -7,8 +7,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/web" @@ -43,7 +43,7 @@ func (s *CorrelationsService) registerAPIEndpoints() { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) createHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) createHandler(c *contextmodel.ReqContext) response.Response { cmd := CreateCorrelationCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -93,7 +93,7 @@ type CreateCorrelationResponse struct { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) deleteHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) deleteHandler(c *contextmodel.ReqContext) response.Response { cmd := DeleteCorrelationCommand{ UID: web.Params(c.Req)[":correlationUID"], SourceUID: web.Params(c.Req)[":uid"], @@ -147,7 +147,7 @@ type DeleteCorrelationResponse struct { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) updateHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) updateHandler(c *contextmodel.ReqContext) response.Response { cmd := UpdateCorrelationCommand{} if err := web.Bind(c.Req, &cmd); err != nil { if errors.Is(err, ErrUpdateCorrelationEmptyParams) { @@ -208,7 +208,7 @@ type UpdateCorrelationResponse struct { // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) getCorrelationHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) getCorrelationHandler(c *contextmodel.ReqContext) response.Response { query := GetCorrelationQuery{ UID: web.Params(c.Req)[":correlationUID"], SourceUID: web.Params(c.Req)[":uid"], @@ -255,7 +255,7 @@ type GetCorrelationResponse struct { // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) getCorrelationsBySourceUIDHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) getCorrelationsBySourceUIDHandler(c *contextmodel.ReqContext) response.Response { query := GetCorrelationsBySourceUIDQuery{ SourceUID: web.Params(c.Req)[":uid"], OrgId: c.OrgID, @@ -298,7 +298,7 @@ type GetCorrelationsBySourceUIDResponse struct { // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (s *CorrelationsService) getCorrelationsHandler(c *models.ReqContext) response.Response { +func (s *CorrelationsService) getCorrelationsHandler(c *contextmodel.ReqContext) response.Response { query := GetCorrelationsQuery{ OrgId: c.OrgID, } diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index f491d645bdc..24ddb46c9f4 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -7,9 +7,9 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/quota" @@ -55,7 +55,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR // 412: preconditionFailedError // 422: unprocessableEntityError // 500: internalServerError -func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Response { +func (api *ImportDashboardAPI) ImportDashboard(c *contextmodel.ReqContext) response.Response { req := dashboardimport.ImportDashboardRequest{} if err := web.Bind(c.Req, &req); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -84,12 +84,12 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re } type QuotaService interface { - QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) + QuotaReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) } -type quotaServiceFunc func(c *models.ReqContext, target quota.TargetSrv) (bool, error) +type quotaServiceFunc func(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) -func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func (fn quotaServiceFunc) QuotaReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) { return fn(c, target) } diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index d688e019109..6d5a07b8862 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" @@ -166,10 +166,10 @@ func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport. return nil, nil } -func quotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func quotaReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) { return true, nil } -func quotaNotReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func quotaNotReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) { return false, nil } diff --git a/pkg/services/dashboards/accesscontrol.go b/pkg/services/dashboards/accesscontrol.go index cd0bf9333d2..3c401ada3e1 100644 --- a/pkg/services/dashboards/accesscontrol.go +++ b/pkg/services/dashboards/accesscontrol.go @@ -5,6 +5,7 @@ import ( "strings" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/folder" ) const ( @@ -38,7 +39,7 @@ var ( ) // NewFolderNameScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "folders:name:" into an uid based scope. -func NewFolderNameScopeResolver(db Store, folderDB FolderStore) (string, ac.ScopeAttributeResolver) { +func NewFolderNameScopeResolver(db Store, folderDB FolderStore, folderSvc folder.Service) (string, ac.ScopeAttributeResolver) { prefix := ScopeFoldersProvider.GetResourceScopeName("") return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) { if !strings.HasPrefix(scope, prefix) { @@ -52,12 +53,19 @@ func NewFolderNameScopeResolver(db Store, folderDB FolderStore) (string, ac.Scop if err != nil { return nil, err } - return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, nil + + result, err := getInheritedScopes(ctx, folder.OrgID, folder.UID, folderSvc) + if err != nil { + return nil, err + } + + result = append([]string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, result...) + return result, nil }) } // NewFolderIDScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "folders:id:" into an uid based scope. -func NewFolderIDScopeResolver(db Store, folderDB FolderStore) (string, ac.ScopeAttributeResolver) { +func NewFolderIDScopeResolver(db Store, folderDB FolderStore, folderSvc folder.Service) (string, ac.ScopeAttributeResolver) { prefix := ScopeFoldersProvider.GetResourceScope("") return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) { if !strings.HasPrefix(scope, prefix) { @@ -78,13 +86,19 @@ func NewFolderIDScopeResolver(db Store, folderDB FolderStore) (string, ac.ScopeA return nil, err } - return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, nil + result, err := getInheritedScopes(ctx, folder.OrgID, folder.UID, folderSvc) + if err != nil { + return nil, err + } + + result = append([]string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, result...) + return result, nil }) } // NewDashboardIDScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "dashboards:id:" // into uid based scopes for both dashboard and folder -func NewDashboardIDScopeResolver(db Store, folderDB FolderStore) (string, ac.ScopeAttributeResolver) { +func NewDashboardIDScopeResolver(db Store, folderDB FolderStore, folderSvc folder.Service) (string, ac.ScopeAttributeResolver) { prefix := ScopeDashboardsProvider.GetResourceScope("") return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) { if !strings.HasPrefix(scope, prefix) { @@ -101,13 +115,13 @@ func NewDashboardIDScopeResolver(db Store, folderDB FolderStore) (string, ac.Sco return nil, err } - return resolveDashboardScope(ctx, db, folderDB, orgID, dashboard) + return resolveDashboardScope(ctx, db, folderDB, orgID, dashboard, folderSvc) }) } // NewDashboardUIDScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "dashboards:uid:" // into uid based scopes for both dashboard and folder -func NewDashboardUIDScopeResolver(db Store, folderDB FolderStore) (string, ac.ScopeAttributeResolver) { +func NewDashboardUIDScopeResolver(db Store, folderDB FolderStore, folderSvc folder.Service) (string, ac.ScopeAttributeResolver) { prefix := ScopeDashboardsProvider.GetResourceScopeUID("") return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) { if !strings.HasPrefix(scope, prefix) { @@ -124,11 +138,11 @@ func NewDashboardUIDScopeResolver(db Store, folderDB FolderStore) (string, ac.Sc return nil, err } - return resolveDashboardScope(ctx, db, folderDB, orgID, dashboard) + return resolveDashboardScope(ctx, db, folderDB, orgID, dashboard, folderSvc) }) } -func resolveDashboardScope(ctx context.Context, db Store, folderDB FolderStore, orgID int64, dashboard *Dashboard) ([]string, error) { +func resolveDashboardScope(ctx context.Context, db Store, folderDB FolderStore, orgID int64, dashboard *Dashboard, folderSvc folder.Service) ([]string, error) { var folderUID string if dashboard.FolderID < 0 { return []string{ScopeDashboardsProvider.GetResourceScopeUID(dashboard.UID)}, nil @@ -144,8 +158,36 @@ func resolveDashboardScope(ctx context.Context, db Store, folderDB FolderStore, folderUID = folder.UID } - return []string{ + result, err := getInheritedScopes(ctx, orgID, folderUID, folderSvc) + if err != nil { + return nil, err + } + + result = append([]string{ ScopeDashboardsProvider.GetResourceScopeUID(dashboard.UID), ScopeFoldersProvider.GetResourceScopeUID(folderUID), - }, nil + }, + result..., + ) + + return result, nil +} + +func getInheritedScopes(ctx context.Context, orgID int64, folderUID string, folderSvc folder.Service) ([]string, error) { + ancestors, err := folderSvc.GetParents(ctx, folder.GetParentsQuery{ + UID: folderUID, + OrgID: orgID, + }) + + if err != nil { + // TODO return a specific error + return nil, err + } + + result := make([]string, 0, len(ancestors)) + for _, ff := range ancestors { + result = append(result, ScopeFoldersProvider.GetResourceScopeUID(ff.UID)) + } + + return result, nil } diff --git a/pkg/services/dashboards/accesscontrol_test.go b/pkg/services/dashboards/accesscontrol_test.go index ae7f319fc98..19e78f48c81 100644 --- a/pkg/services/dashboards/accesscontrol_test.go +++ b/pkg/services/dashboards/accesscontrol_test.go @@ -7,17 +7,19 @@ import ( "strconv" "testing" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/util" ) func TestNewFolderNameScopeResolver(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) { - prefix, _ := NewFolderNameScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + prefix, _ := NewFolderNameScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) require.Equal(t, "folders:name:", prefix) }) @@ -33,7 +35,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { scope := "folders:name:" + title - _, resolver := NewFolderNameScopeResolver(dashboardStore, folderStore) + _, resolver := NewFolderNameScopeResolver(dashboardStore, folderStore, foldertest.NewFakeService()) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) require.NoError(t, err) @@ -43,16 +45,53 @@ func TestNewFolderNameScopeResolver(t *testing.T) { folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) }) + t.Run("resolver should include inherited scopes if any", func(t *testing.T) { + dashboardStore := &FakeDashboardStore{} + orgId := rand.Int63() + title := "Very complex :title with: and /" + util.GenerateShortUID() + + db := &folder.Folder{Title: title, ID: rand.Int63(), UID: util.GenerateShortUID()} + + folderStore := NewFakeFolderStore(t) + folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() + + scope := "folders:name:" + title + + folderSvc := foldertest.NewFakeService() + folderSvc.ExpectedFolders = []*folder.Folder{ + { + UID: "parent", + }, + { + UID: "grandparent", + }, + } + _, resolver := NewFolderNameScopeResolver(dashboardStore, folderStore, folderSvc) + + resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) + require.NoError(t, err) + require.Len(t, resolvedScopes, 3) + + if diff := cmp.Diff([]string{ + fmt.Sprintf("folders:uid:%v", db.UID), + "folders:uid:parent", + "folders:uid:grandparent", + }, resolvedScopes); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + + folderStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) + }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { dashboardStore := &FakeDashboardStore{} - _, resolver := NewFolderNameScopeResolver(dashboardStore, NewFakeFolderStore(t)) + _, resolver := NewFolderNameScopeResolver(dashboardStore, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:id:123") require.ErrorIs(t, err, ac.ErrInvalidScope) }) t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) { dashboardStore := &FakeDashboardStore{} - _, resolver := NewFolderNameScopeResolver(dashboardStore, NewFakeFolderStore(t)) + _, resolver := NewFolderNameScopeResolver(dashboardStore, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:name:") require.ErrorIs(t, err, ac.ErrInvalidScope) @@ -61,7 +100,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { dashboardStore := &FakeDashboardStore{} folderStore := NewFakeFolderStore(t) - _, resolver := NewFolderNameScopeResolver(dashboardStore, folderStore) + _, resolver := NewFolderNameScopeResolver(dashboardStore, folderStore, foldertest.NewFakeService()) orgId := rand.Int63() folderStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(nil, ErrDashboardNotFound).Once() @@ -76,7 +115,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { func TestNewFolderIDScopeResolver(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) { - prefix, _ := NewFolderIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + prefix, _ := NewFolderIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) require.Equal(t, "folders:id:", prefix) }) @@ -84,7 +123,7 @@ func TestNewFolderIDScopeResolver(t *testing.T) { dashboardStore := &FakeDashboardStore{} folderStore := NewFakeFolderStore(t) - _, resolver := NewFolderIDScopeResolver(dashboardStore, folderStore) + _, resolver := NewFolderIDScopeResolver(dashboardStore, folderStore, foldertest.NewFakeService()) orgId := rand.Int63() uid := util.GenerateShortUID() @@ -101,9 +140,46 @@ func TestNewFolderIDScopeResolver(t *testing.T) { folderStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.ID) }) + t.Run("resolver should should include inherited scopes if any", func(t *testing.T) { + dashboardStore := &FakeDashboardStore{} + folderStore := NewFakeFolderStore(t) + + folderSvc := foldertest.NewFakeService() + folderSvc.ExpectedFolders = []*folder.Folder{ + { + UID: "parent", + }, + { + UID: "grandparent", + }, + } + _, resolver := NewFolderIDScopeResolver(dashboardStore, folderStore, folderSvc) + + orgId := rand.Int63() + uid := util.GenerateShortUID() + + db := &folder.Folder{ID: rand.Int63(), UID: uid} + folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() + + scope := "folders:id:" + strconv.FormatInt(db.ID, 10) + + resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) + require.NoError(t, err) + require.Len(t, resolvedScopes, 3) + + if diff := cmp.Diff([]string{ + fmt.Sprintf("folders:uid:%v", db.UID), + "folders:uid:parent", + "folders:uid:grandparent", + }, resolvedScopes); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + + folderStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.ID) + }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { dashboardStore := &FakeDashboardStore{} - _, resolver := NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t)) + _, resolver := NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:uid:123") require.ErrorIs(t, err, ac.ErrInvalidScope) @@ -114,7 +190,7 @@ func TestNewFolderIDScopeResolver(t *testing.T) { dashboardStore = &FakeDashboardStore{} orgId = rand.Int63() scope = "folders:id:0" - _, resolver = NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t)) + _, resolver = NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t), foldertest.NewFakeService()) ) resolved, err := resolver.Resolve(context.Background(), orgId, scope) @@ -126,7 +202,7 @@ func TestNewFolderIDScopeResolver(t *testing.T) { t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) { dashboardStore := &FakeDashboardStore{} - _, resolver := NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t)) + _, resolver := NewFolderIDScopeResolver(dashboardStore, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:id:") require.ErrorIs(t, err, ac.ErrInvalidScope) @@ -135,7 +211,7 @@ func TestNewFolderIDScopeResolver(t *testing.T) { dashboardStore := &FakeDashboardStore{} folderStore := NewFakeFolderStore(t) - _, resolver := NewFolderIDScopeResolver(dashboardStore, folderStore) + _, resolver := NewFolderIDScopeResolver(dashboardStore, folderStore, foldertest.NewFakeService()) orgId := rand.Int63() folderStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(nil, ErrDashboardNotFound).Once() @@ -149,7 +225,7 @@ func TestNewFolderIDScopeResolver(t *testing.T) { func TestNewDashboardIDScopeResolver(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) { - prefix, _ := NewDashboardIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + prefix, _ := NewDashboardIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) require.Equal(t, "dashboards:id:", prefix) }) @@ -157,7 +233,7 @@ func TestNewDashboardIDScopeResolver(t *testing.T) { store := &FakeDashboardStore{} folderStore := NewFakeFolderStore(t) - _, resolver := NewDashboardIDScopeResolver(store, folderStore) + _, resolver := NewDashboardIDScopeResolver(store, folderStore, foldertest.NewFakeService()) orgID := rand.Int63() folder := &folder.Folder{ID: 2, UID: "2"} @@ -174,15 +250,52 @@ func TestNewDashboardIDScopeResolver(t *testing.T) { require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) }) + t.Run("resolver should inlude inherited scopes if any", func(t *testing.T) { + store := &FakeDashboardStore{} + folderStore := NewFakeFolderStore(t) + + folderSvc := foldertest.NewFakeService() + folderSvc.ExpectedFolders = []*folder.Folder{ + { + UID: "parent", + }, + { + UID: "grandparent", + }, + } + _, resolver := NewDashboardIDScopeResolver(store, folderStore, folderSvc) + + orgID := rand.Int63() + folder := &folder.Folder{ID: 2, UID: "2"} + dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} + + store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() + folderStore.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() + + scope := ac.Scope("dashboards", "id", strconv.FormatInt(dashboard.ID, 10)) + resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) + require.NoError(t, err) + require.Len(t, resolvedScopes, 4) + + if diff := cmp.Diff([]string{ + fmt.Sprintf("dashboards:uid:%s", dashboard.UID), + fmt.Sprintf("folders:uid:%s", folder.UID), + "folders:uid:parent", + "folders:uid:grandparent", + }, resolvedScopes); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { - _, resolver := NewDashboardIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + _, resolver := NewDashboardIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "dashboards:uid:123") require.ErrorIs(t, err, ac.ErrInvalidScope) }) t.Run("resolver should convert folderID 0 to general uid scope for the folder scope", func(t *testing.T) { store := &FakeDashboardStore{} - _, resolver := NewDashboardIDScopeResolver(store, NewFakeFolderStore(t)) + _, resolver := NewDashboardIDScopeResolver(store, NewFakeFolderStore(t), foldertest.NewFakeService()) dashboard := &Dashboard{ID: 1, FolderID: 0, UID: "1"} store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil) @@ -197,14 +310,14 @@ func TestNewDashboardIDScopeResolver(t *testing.T) { func TestNewDashboardUIDScopeResolver(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) { - prefix, _ := NewDashboardUIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + prefix, _ := NewDashboardUIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) require.Equal(t, "dashboards:uid:", prefix) }) t.Run("resolver should convert to uid dashboard and folder scope", func(t *testing.T) { store := &FakeDashboardStore{} folderStore := NewFakeFolderStore(t) - _, resolver := NewDashboardUIDScopeResolver(store, folderStore) + _, resolver := NewDashboardUIDScopeResolver(store, folderStore, foldertest.NewFakeService()) orgID := rand.Int63() folder := &folder.Folder{ID: 2, UID: "2"} @@ -221,15 +334,53 @@ func TestNewDashboardUIDScopeResolver(t *testing.T) { require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) }) + t.Run("resolver should include inherited scopes if any", func(t *testing.T) { + store := &FakeDashboardStore{} + folderStore := NewFakeFolderStore(t) + + folderSvc := foldertest.NewFakeService() + folderSvc.ExpectedFolders = []*folder.Folder{ + { + UID: "parent", + }, + { + UID: "grandparent", + }, + } + + _, resolver := NewDashboardUIDScopeResolver(store, folderStore, folderSvc) + + orgID := rand.Int63() + folder := &folder.Folder{ID: 2, UID: "2"} + dashboard := &Dashboard{ID: 1, FolderID: folder.ID, UID: "1"} + + store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() + folderStore.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() + + scope := ac.Scope("dashboards", "uid", dashboard.UID) + resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) + require.NoError(t, err) + require.Len(t, resolvedScopes, 4) + + if diff := cmp.Diff([]string{ + fmt.Sprintf("dashboards:uid:%s", dashboard.UID), + fmt.Sprintf("folders:uid:%s", folder.UID), + "folders:uid:parent", + "folders:uid:grandparent", + }, resolvedScopes); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { - _, resolver := NewDashboardUIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t)) + _, resolver := NewDashboardUIDScopeResolver(&FakeDashboardStore{}, NewFakeFolderStore(t), foldertest.NewFakeService()) _, err := resolver.Resolve(context.Background(), rand.Int63(), "dashboards:id:123") require.ErrorIs(t, err, ac.ErrInvalidScope) }) t.Run("resolver should convert folderID 0 to general uid scope for the folder scope", func(t *testing.T) { store := &FakeDashboardStore{} - _, resolver := NewDashboardUIDScopeResolver(store, NewFakeFolderStore(t)) + _, resolver := NewDashboardUIDScopeResolver(store, NewFakeFolderStore(t), foldertest.NewFakeService()) dashboard := &Dashboard{ID: 1, FolderID: 0, UID: "1"} store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil) diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index f6fa8e2faf2..54092578208 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -3,7 +3,6 @@ package dashboards import ( "context" - "github.com/grafana/grafana/pkg/models" alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/quota" @@ -15,18 +14,18 @@ import ( type DashboardService interface { BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error - FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) - GetDashboard(ctx context.Context, query *GetDashboardQuery) error - GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) error - GetDashboards(ctx context.Context, query *GetDashboardsQuery) error - GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) error - GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) error - HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error - HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error + FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) + GetDashboard(ctx context.Context, query *GetDashboardQuery) (*Dashboard, error) + GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) ([]*DashboardACLInfoDTO, error) + GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) + GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) + GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) + HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) + HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*Dashboard, error) - SearchDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) error + SearchDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) error UpdateDashboardACL(ctx context.Context, uid int64, items []*DashboardACL) error DeleteACLByUser(ctx context.Context, userID int64) error CountDashboardsInFolder(ctx context.Context, query *CountDashboardsInFolderQuery) (int64, error) @@ -34,7 +33,7 @@ type DashboardService interface { // PluginService is a service for operating on plugin dashboards. type PluginService interface { - GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) error + GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) ([]*Dashboard, error) } // DashboardProvisioningService is a service for operating on provisioned dashboards. @@ -57,20 +56,20 @@ type DashboardProvisioningService interface { type Store interface { DeleteDashboard(ctx context.Context, cmd *DeleteDashboardCommand) error DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *DeleteOrphanedProvisionedDashboardsCommand) error - FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) + FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) GetDashboard(ctx context.Context, query *GetDashboardQuery) (*Dashboard, error) - GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) error - GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) error - GetDashboards(ctx context.Context, query *GetDashboardsQuery) error + GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) ([]*DashboardACLInfoDTO, error) + GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) + GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) // GetDashboardsByPluginID retrieves dashboards identified by plugin. - GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) error - GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) error + GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) ([]*Dashboard, error) + GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) GetProvisionedDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) - HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error - HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error - // SaveAlerts saves dashboard alertmodels. + HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) + HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) + // SaveAlerts saves dashboard alerts. SaveAlerts(ctx context.Context, dashID int64, alerts []*alertmodels.Alert) error SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) SaveProvisionedDashboard(ctx context.Context, cmd SaveDashboardCommand, provisioning *DashboardProvisioning) (*Dashboard, error) diff --git a/pkg/models/dashboard_acl.go b/pkg/services/dashboards/dashboard_acl.go similarity index 98% rename from pkg/models/dashboard_acl.go rename to pkg/services/dashboards/dashboard_acl.go index f8cb7a3adf7..2068e7d5832 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/services/dashboards/dashboard_acl.go @@ -1,4 +1,4 @@ -package models +package dashboards import ( "errors" diff --git a/pkg/models/dashboard_acl_test.go b/pkg/services/dashboards/dashboard_acl_test.go similarity index 96% rename from pkg/models/dashboard_acl_test.go rename to pkg/services/dashboards/dashboard_acl_test.go index f02b132fb9e..97ba21dd58a 100644 --- a/pkg/models/dashboard_acl_test.go +++ b/pkg/services/dashboards/dashboard_acl_test.go @@ -1,4 +1,4 @@ -package models +package dashboards import ( "fmt" diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 65ead437663..3da367b22be 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -5,7 +5,7 @@ package dashboards import ( context "context" - models "github.com/grafana/grafana/pkg/models" + folder "github.com/grafana/grafana/pkg/services/folder" mock "github.com/stretchr/testify/mock" ) @@ -87,11 +87,11 @@ func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId } // FindDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { +func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { ret := _m.Called(ctx, query) var r0 []DashboardSearchProjection - if rf, ok := ret.Get(0).(func(context.Context, *models.FindPersistedDashboardsQuery) []DashboardSearchProjection); ok { + if rf, ok := ret.Get(0).(func(context.Context, *FindPersistedDashboardsQuery) []DashboardSearchProjection); ok { r0 = rf(ctx, query) } else { if ret.Get(0) != nil { @@ -100,7 +100,7 @@ func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *model } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, *models.FindPersistedDashboardsQuery) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *FindPersistedDashboardsQuery) error); ok { r1 = rf(ctx, query) } else { r1 = ret.Error(1) @@ -110,101 +110,160 @@ func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *model } // GetDashboard provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) GetDashboard(ctx context.Context, query *GetDashboardQuery) error { +func (_m *FakeDashboardService) GetDashboard(ctx context.Context, query *GetDashboardQuery) (*Dashboard, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardQuery) error); ok { + var r0 *Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardQuery) *Dashboard); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Dashboard) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardACLInfoList provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) error { +func (_m *FakeDashboardService) GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) ([]*DashboardACLInfoDTO, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardACLInfoListQuery) error); ok { + var r0 []*DashboardACLInfoDTO + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardACLInfoListQuery) []*DashboardACLInfoDTO); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*DashboardACLInfoDTO) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardACLInfoListQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardTags provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) error { +func (_m *FakeDashboardService) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardTagsQuery) error); ok { + var r0 []*DashboardTagCloudItem + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardTagsQuery) []*DashboardTagCloudItem); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*DashboardTagCloudItem) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardTagsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardUIDByID provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) error { +func (_m *FakeDashboardService) GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) error); ok { + var r0 *DashboardRef + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) *DashboardRef); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*DashboardRef) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardRefByIDQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) GetDashboards(ctx context.Context, query *GetDashboardsQuery) error { +func (_m *FakeDashboardService) GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) error); ok { + var r0 []*Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) []*Dashboard); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // HasAdminPermissionInDashboardsOrFolders provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { +func (_m *FakeDashboardService) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.HasAdminPermissionInDashboardsOrFoldersQuery) error); ok { + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, *folder.HasAdminPermissionInDashboardsOrFoldersQuery) bool); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(bool) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *folder.HasAdminPermissionInDashboardsOrFoldersQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // HasEditPermissionInFolders provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { +func (_m *FakeDashboardService) HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.HasEditPermissionInFoldersQuery) error); ok { + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, *folder.HasEditPermissionInFoldersQuery) bool); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(bool) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *folder.HasEditPermissionInFoldersQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // ImportDashboard provides a mock function with given fields: ctx, dto @@ -268,11 +327,11 @@ func (_m *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDash } // SearchDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) error { +func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) error { ret := _m.Called(ctx, query) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.FindPersistedDashboardsQuery) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *FindPersistedDashboardsQuery) error); ok { r0 = rf(ctx, query) } else { r0 = ret.Error(0) diff --git a/pkg/services/dashboards/database/acl.go b/pkg/services/dashboards/database/acl.go index 93256cead83..4e28b944622 100644 --- a/pkg/services/dashboards/database/acl.go +++ b/pkg/services/dashboards/database/acl.go @@ -4,8 +4,8 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" ) @@ -14,9 +14,9 @@ import ( // 1) Permissions for the dashboard // 2) permissions for its parent folder // 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions -func (d *DashboardStore) GetDashboardACLInfoList(ctx context.Context, query *dashboards.GetDashboardACLInfoListQuery) error { +func (d *DashboardStore) GetDashboardACLInfoList(ctx context.Context, query *dashboards.GetDashboardACLInfoListQuery) ([]*dashboards.DashboardACLInfoDTO, error) { + queryResult := make([]*dashboards.DashboardACLInfoDTO, 0) outerErr := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { - query.Result = make([]*dashboards.DashboardACLInfoDTO, 0) falseStr := d.store.GetDialect().BooleanStr(false) if query.DashboardID == 0 { @@ -40,7 +40,7 @@ func (d *DashboardStore) GetDashboardACLInfoList(ctx context.Context, query *das falseStr + ` AS inherited FROM dashboard_acl as da WHERE da.dashboard_id = -1` - return dbSession.SQL(sql).Find(&query.Result) + return dbSession.SQL(sql).Find(&queryResult) } rawSQL := ` @@ -82,32 +82,32 @@ func (d *DashboardStore) GetDashboardACLInfoList(ctx context.Context, query *das ORDER BY da.id ASC ` - return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&query.Result) + return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&queryResult) }) if outerErr != nil { - return outerErr + return nil, outerErr } - for _, p := range query.Result { + for _, p := range queryResult { p.PermissionName = p.Permission.String() } - return nil + return queryResult, nil } // HasEditPermissionInFolders validates that an user have access to a certain folder -func (d *DashboardStore) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { - return d.store.WithDbSession(ctx, func(dbSession *db.Session) error { - if query.SignedInUser.HasRole(org.RoleEditor) { - query.Result = true - return nil - } - +func (d *DashboardStore) HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) { + var queryResult bool + if query.SignedInUser.HasRole(org.RoleEditor) { + queryResult = true + return queryResult, nil + } + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { builder := db.NewSqlBuilder(d.cfg, d.store.GetDialect()) builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgID, d.store.GetDialect().BooleanStr(true)) - builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_EDIT) + builder.WriteDashboardPermissionFilter(query.SignedInUser, dashboards.PERMISSION_EDIT) type folderCount struct { Count int64 @@ -119,22 +119,27 @@ func (d *DashboardStore) HasEditPermissionInFolders(ctx context.Context, query * return err } - query.Result = len(resp) > 0 && resp[0].Count > 0 + queryResult = len(resp) > 0 && resp[0].Count > 0 return nil }) + if err != nil { + return queryResult, err + } + return queryResult, nil } -func (d *DashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { - return d.store.WithDbSession(ctx, func(dbSession *db.Session) error { +func (d *DashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { + var queryResult bool + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { if query.SignedInUser.HasRole(org.RoleAdmin) { - query.Result = true + queryResult = true return nil } builder := db.NewSqlBuilder(d.cfg, d.store.GetDialect()) builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ?", query.SignedInUser.OrgID) - builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN) + builder.WriteDashboardPermissionFilter(query.SignedInUser, dashboards.PERMISSION_ADMIN) type folderCount struct { Count int64 @@ -145,10 +150,14 @@ func (d *DashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Con return err } - query.Result = len(resp) > 0 && resp[0].Count > 0 + queryResult = len(resp) > 0 && resp[0].Count > 0 return nil }) + if err != nil { + return queryResult, err + } + return queryResult, nil } func (d *DashboardStore) DeleteACLByUser(ctx context.Context, userID int64) error { diff --git a/pkg/services/dashboards/database/acl_test.go b/pkg/services/dashboards/database/acl_test.go index d632170b0cf..6efeb8d3921 100644 --- a/pkg/services/dashboards/database/acl_test.go +++ b/pkg/services/dashboards/database/acl_test.go @@ -7,7 +7,6 @@ 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/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -45,43 +44,43 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { err := updateDashboardACL(t, dashboardStore, savedFolder.ID, dashboards.DashboardACL{ OrgID: 1, DashboardID: savedFolder.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) - require.Equal(t, models.ErrDashboardACLInfoMissing, err) + require.Equal(t, dashboards.ErrDashboardACLInfoMissing, err) }) t.Run("Folder acl should include default acl", func(t *testing.T) { setup(t) query := dashboards.GetDashboardACLInfoListQuery{DashboardID: savedFolder.ID, OrgID: 1} - err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 2, len(query.Result)) + require.Equal(t, 2, len(queryResult)) defaultPermissionsId := int64(-1) - require.Equal(t, defaultPermissionsId, query.Result[0].DashboardID) - require.Equal(t, org.RoleViewer, *query.Result[0].Role) - require.False(t, query.Result[0].Inherited) - require.Equal(t, defaultPermissionsId, query.Result[1].DashboardID) - require.Equal(t, org.RoleEditor, *query.Result[1].Role) - require.False(t, query.Result[1].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[0].DashboardID) + require.Equal(t, org.RoleViewer, *queryResult[0].Role) + require.False(t, queryResult[0].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[1].DashboardID) + require.Equal(t, org.RoleEditor, *queryResult[1].Role) + require.False(t, queryResult[1].Inherited) }) t.Run("Dashboard acl should include acl for parent folder", func(t *testing.T) { setup(t) query := dashboards.GetDashboardACLInfoListQuery{DashboardID: childDash.ID, OrgID: 1} - err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 2, len(query.Result)) + require.Equal(t, 2, len(queryResult)) defaultPermissionsId := int64(-1) - require.Equal(t, defaultPermissionsId, query.Result[0].DashboardID) - require.Equal(t, org.RoleViewer, *query.Result[0].Role) - require.True(t, query.Result[0].Inherited) - require.Equal(t, defaultPermissionsId, query.Result[1].DashboardID) - require.Equal(t, org.RoleEditor, *query.Result[1].Role) - require.True(t, query.Result[1].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[0].DashboardID) + require.Equal(t, org.RoleViewer, *queryResult[0].Role) + require.True(t, queryResult[0].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[1].DashboardID) + require.Equal(t, org.RoleEditor, *queryResult[1].Role) + require.True(t, queryResult[1].Inherited) }) t.Run("Folder with removed default permissions returns no acl items", func(t *testing.T) { @@ -90,10 +89,10 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { require.Nil(t, err) query := dashboards.GetDashboardACLInfoListQuery{DashboardID: childDash.ID, OrgID: 1} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 0, len(query.Result)) + require.Equal(t, 0, len(queryResult)) }) t.Run("Given a dashboard folder and a user", func(t *testing.T) { @@ -103,18 +102,18 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, UserID: currentUser.ID, DashboardID: savedFolder.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) t.Run("When reading dashboard acl should include acl for parent folder", func(t *testing.T) { query := dashboards.GetDashboardACLInfoListQuery{DashboardID: childDash.ID, OrgID: 1} - err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 1, len(query.Result)) - require.Equal(t, savedFolder.ID, query.Result[0].DashboardID) + require.Equal(t, 1, len(queryResult)) + require.Equal(t, savedFolder.ID, queryResult[0].DashboardID) }) t.Run("Given child dashboard permission", func(t *testing.T) { @@ -122,21 +121,21 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, UserID: currentUser.ID, DashboardID: childDash.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) t.Run("When reading dashboard acl should include acl for parent folder and child", func(t *testing.T) { query := dashboards.GetDashboardACLInfoListQuery{OrgID: 1, DashboardID: childDash.ID} - err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 2, len(query.Result)) - require.Equal(t, savedFolder.ID, query.Result[0].DashboardID) - require.True(t, query.Result[0].Inherited) - require.Equal(t, childDash.ID, query.Result[1].DashboardID) - require.False(t, query.Result[1].Inherited) + require.Equal(t, 2, len(queryResult)) + require.Equal(t, savedFolder.ID, queryResult[0].DashboardID) + require.True(t, queryResult[0].Inherited) + require.Equal(t, childDash.ID, queryResult[1].DashboardID) + require.False(t, queryResult[1].Inherited) }) }) }) @@ -147,25 +146,25 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, UserID: currentUser.ID, DashboardID: childDash.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) query := dashboards.GetDashboardACLInfoListQuery{OrgID: 1, DashboardID: childDash.ID} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) defaultPermissionsId := int64(-1) - require.Equal(t, 3, len(query.Result)) - require.Equal(t, defaultPermissionsId, query.Result[0].DashboardID) - require.Equal(t, org.RoleViewer, *query.Result[0].Role) - require.True(t, query.Result[0].Inherited) - require.Equal(t, defaultPermissionsId, query.Result[1].DashboardID) - require.Equal(t, org.RoleEditor, *query.Result[1].Role) - require.True(t, query.Result[1].Inherited) - require.Equal(t, childDash.ID, query.Result[2].DashboardID) - require.False(t, query.Result[2].Inherited) + require.Equal(t, 3, len(queryResult)) + require.Equal(t, defaultPermissionsId, queryResult[0].DashboardID) + require.Equal(t, org.RoleViewer, *queryResult[0].Role) + require.True(t, queryResult[0].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[1].DashboardID) + require.Equal(t, org.RoleEditor, *queryResult[1].Role) + require.True(t, queryResult[1].Inherited) + require.Equal(t, childDash.ID, queryResult[2].DashboardID) + require.False(t, queryResult[2].Inherited) }) t.Run("Add and delete dashboard permission", func(t *testing.T) { @@ -174,28 +173,28 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, UserID: currentUser.ID, DashboardID: savedFolder.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) q1 := &dashboards.GetDashboardACLInfoListQuery{DashboardID: savedFolder.ID, OrgID: 1} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), q1) + q1Result, err := dashboardStore.GetDashboardACLInfoList(context.Background(), q1) require.Nil(t, err) - require.Equal(t, savedFolder.ID, q1.Result[0].DashboardID) - require.Equal(t, models.PERMISSION_EDIT, q1.Result[0].Permission) - require.Equal(t, "Edit", q1.Result[0].PermissionName) - require.Equal(t, currentUser.ID, q1.Result[0].UserID) - require.Equal(t, currentUser.Login, q1.Result[0].UserLogin) - require.Equal(t, currentUser.Email, q1.Result[0].UserEmail) + require.Equal(t, savedFolder.ID, q1Result[0].DashboardID) + require.Equal(t, dashboards.PERMISSION_EDIT, q1Result[0].Permission) + require.Equal(t, "Edit", q1Result[0].PermissionName) + require.Equal(t, currentUser.ID, q1Result[0].UserID) + require.Equal(t, currentUser.Login, q1Result[0].UserLogin) + require.Equal(t, currentUser.Email, q1Result[0].UserEmail) err = updateDashboardACL(t, dashboardStore, savedFolder.ID) require.Nil(t, err) q3 := &dashboards.GetDashboardACLInfoListQuery{DashboardID: savedFolder.ID, OrgID: 1} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), q3) + q3Result, err := dashboardStore.GetDashboardACLInfoList(context.Background(), q3) require.Nil(t, err) - require.Equal(t, 0, len(q3.Result)) + require.Equal(t, 0, len(q3Result)) }) t.Run("Should be able to add a user permission for a team", func(t *testing.T) { @@ -208,16 +207,16 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, TeamID: team1.ID, DashboardID: savedFolder.ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) q1 := &dashboards.GetDashboardACLInfoListQuery{DashboardID: savedFolder.ID, OrgID: 1} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), q1) + q1Result, err := dashboardStore.GetDashboardACLInfoList(context.Background(), q1) require.Nil(t, err) - require.Equal(t, savedFolder.ID, q1.Result[0].DashboardID) - require.Equal(t, models.PERMISSION_EDIT, q1.Result[0].Permission) - require.Equal(t, team1.ID, q1.Result[0].TeamID) + require.Equal(t, savedFolder.ID, q1Result[0].DashboardID) + require.Equal(t, dashboards.PERMISSION_EDIT, q1Result[0].Permission) + require.Equal(t, team1.ID, q1Result[0].TeamID) }) t.Run("Should be able to update an existing permission for a team", func(t *testing.T) { @@ -229,17 +228,17 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { OrgID: 1, TeamID: team1.ID, DashboardID: savedFolder.ID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, }) require.Nil(t, err) q3 := &dashboards.GetDashboardACLInfoListQuery{DashboardID: savedFolder.ID, OrgID: 1} - err = dashboardStore.GetDashboardACLInfoList(context.Background(), q3) + q3Result, err := dashboardStore.GetDashboardACLInfoList(context.Background(), q3) require.Nil(t, err) - require.Equal(t, 1, len(q3.Result)) - require.Equal(t, savedFolder.ID, q3.Result[0].DashboardID) - require.Equal(t, models.PERMISSION_ADMIN, q3.Result[0].Permission) - require.Equal(t, team1.ID, q3.Result[0].TeamID) + require.Equal(t, 1, len(q3Result)) + require.Equal(t, savedFolder.ID, q3Result[0].DashboardID) + require.Equal(t, dashboards.PERMISSION_ADMIN, q3Result[0].Permission) + require.Equal(t, team1.ID, q3Result[0].TeamID) }) }) @@ -250,17 +249,17 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { query := dashboards.GetDashboardACLInfoListQuery{DashboardID: rootFolderId, OrgID: 1} - err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardACLInfoList(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 2, len(query.Result)) + require.Equal(t, 2, len(queryResult)) defaultPermissionsId := int64(-1) - require.Equal(t, defaultPermissionsId, query.Result[0].DashboardID) - require.Equal(t, org.RoleViewer, *query.Result[0].Role) - require.False(t, query.Result[0].Inherited) - require.Equal(t, defaultPermissionsId, query.Result[1].DashboardID) - require.Equal(t, org.RoleEditor, *query.Result[1].Role) - require.False(t, query.Result[1].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[0].DashboardID) + require.Equal(t, org.RoleViewer, *queryResult[0].Role) + require.False(t, queryResult[0].Inherited) + require.Equal(t, defaultPermissionsId, queryResult[1].DashboardID) + require.Equal(t, org.RoleEditor, *queryResult[1].Role) + require.False(t, queryResult[1].Inherited) }) t.Run("Delete acl by user", func(t *testing.T) { diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index ec5a46893fe..1d8b2bc0d47 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/dashboards" @@ -206,26 +205,37 @@ func (d *DashboardStore) GetProvisionedDashboardData(ctx context.Context, name s } func (d *DashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd dashboards.SaveDashboardCommand, provisioning *dashboards.DashboardProvisioning) (*dashboards.Dashboard, error) { - err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if err := saveDashboard(sess, &cmd, d.emitEntityEvent()); err != nil { + var result *dashboards.Dashboard + var err error + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) + if err != nil { return err } if provisioning.Updated == 0 { - provisioning.Updated = cmd.Result.Updated.Unix() + provisioning.Updated = result.Updated.Unix() } - return saveProvisionedData(sess, provisioning, cmd.Result) + return saveProvisionedData(sess, provisioning, result) }) - - return cmd.Result, err + return result, err } func (d *DashboardStore) SaveDashboard(ctx context.Context, cmd dashboards.SaveDashboardCommand) (*dashboards.Dashboard, error) { - err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - return saveDashboard(sess, &cmd, d.emitEntityEvent()) + var result *dashboards.Dashboard + var err error + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) + if err != nil { + return err + } + return nil }) - return cmd.Result, err + if err != nil { + return nil, err + } + return result, err } func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*dashboards.DashboardACL) error { @@ -238,11 +248,11 @@ func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int for _, item := range items { if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing + return dashboards.ErrDashboardACLInfoMissing } if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty + return dashboards.ErrDashboardPermissionDashboardEmpty } sess.Nullable("user_id", "team_id") @@ -476,7 +486,7 @@ func getExistingDashboardByTitleAndFolder(sess *db.Session, dash *dashboards.Das return isParentFolderChanged, nil } -func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitEntityEvent bool) error { +func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitEntityEvent bool) (*dashboards.Dashboard, error) { dash := cmd.GetDashboardModel() userId := cmd.UserID @@ -489,10 +499,10 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE var existing dashboards.Dashboard dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existing) if err != nil { - return err + return nil, err } if !dashWithIdExists { - return dashboards.ErrDashboardNotFound + return nil, dashboards.ErrDashboardNotFound } // check for is someone else has written in between @@ -500,20 +510,20 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE if cmd.Overwrite { dash.SetVersion(existing.Version) } else { - return dashboards.ErrDashboardVersionMismatch + return nil, dashboards.ErrDashboardVersionMismatch } } // do not allow plugin dashboard updates without overwrite flag if existing.PluginID != "" && !cmd.Overwrite { - return dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID} + return nil, dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID} } } if dash.UID == "" { uid, err := generateNewDashboardUid(sess, dash.OrgID) if err != nil { - return err + return nil, err } dash.SetUID(uid) } @@ -545,11 +555,11 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE } if err != nil { - return err + return nil, err } if affectedRows == 0 { - return dashboards.ErrDashboardNotFound + return nil, dashboards.ErrDashboardNotFound } dashVersion := &dashver.DashboardVersion{ @@ -565,14 +575,14 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE // insert version entry if affectedRows, err = sess.Insert(dashVersion); err != nil { - return err + return nil, err } else if affectedRows == 0 { - return dashboards.ErrDashboardNotFound + return nil, dashboards.ErrDashboardNotFound } // delete existing tags if _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.ID); err != nil { - return err + return nil, err } // insert new tags @@ -580,20 +590,18 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE if len(tags) > 0 { for _, tag := range tags { if _, err := sess.Insert(DashboardTag{DashboardId: dash.ID, Term: tag}); err != nil { - return err + return nil, err } } } - cmd.Result = dash - if emitEntityEvent { _, err := sess.Insert(createEntityEvent(dash, store.EntityEventTypeUpdate)) if err != nil { - return err + return dash, err } } - return nil + return dash, nil } func generateNewDashboardUid(sess *db.Session, orgId int64) (string, error) { @@ -750,15 +758,18 @@ func (d *DashboardStore) deleteAlertByIdInternal(alertId int64, reason string, s return nil } -func (d *DashboardStore) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) error { - return d.store.WithDbSession(ctx, func(dbSession *db.Session) error { - var dashboards = make([]*dashboards.Dashboard, 0) +func (d *DashboardStore) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { + var dashboards = make([]*dashboards.Dashboard, 0) + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.store.GetDialect().BooleanStr(false) err := dbSession.Where(whereExpr, query.OrgID, query.PluginID).Find(&dashboards) - query.Result = dashboards return err }) + if err != nil { + return nil, err + } + return dashboards, nil } func (d *DashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error { @@ -924,6 +935,7 @@ func (d *DashboardStore) deleteAlertDefinition(dashboardId int64, sess *db.Sessi } func (d *DashboardStore) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { + var queryResult *dashboards.Dashboard err := d.store.WithDbSession(ctx, func(sess *db.Session) error { if query.ID == 0 && len(query.Slug) == 0 && len(query.UID) == 0 { return dashboards.ErrDashboardIdentifierNotSet @@ -940,35 +952,37 @@ func (d *DashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get dashboard.SetID(dashboard.ID) dashboard.SetUID(dashboard.UID) - query.Result = &dashboard + queryResult = &dashboard return nil }) - return query.Result, err + return queryResult, err } -func (d *DashboardStore) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) error { - return d.store.WithDbSession(ctx, func(sess *db.Session) error { +func (d *DashboardStore) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { + us := &dashboards.DashboardRef{} + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = `SELECT uid, slug from dashboard WHERE Id=?` - us := &dashboards.DashboardRef{} exists, err := sess.SQL(rawSQL, query.ID).Get(us) if err != nil { return err } else if !exists { return dashboards.ErrDashboardNotFound } - query.Result = us return nil }) + if err != nil { + return nil, err + } + return us, nil } -func (d *DashboardStore) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) error { - return d.store.WithDbSession(ctx, func(sess *db.Session) error { +func (d *DashboardStore) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { + var dashboards = make([]*dashboards.Dashboard, 0) + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 { return star.ErrCommandValidationFailed } - - var dashboards = make([]*dashboards.Dashboard, 0) var session *xorm.Session if len(query.DashboardIDs) > 0 { session = sess.In("id", query.DashboardIDs) @@ -980,12 +994,15 @@ func (d *DashboardStore) GetDashboards(ctx context.Context, query *dashboards.Ge } err := session.Find(&dashboards) - query.Result = dashboards return err }) + if err != nil { + return nil, err + } + return dashboards, nil } -func (d *DashboardStore) FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { +func (d *DashboardStore) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { filters := []interface{}{ permissions.DashboardPermissionFilter{ OrgRole: query.SignedInUser.OrgRole, @@ -1067,8 +1084,9 @@ func (d *DashboardStore) FindDashboards(ctx context.Context, query *models.FindP return res, nil } -func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) error { - return d.store.WithDbSession(ctx, func(dbSession *db.Session) error { +func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { + queryResult := make([]*dashboards.DashboardTagCloudItem, 0) + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { sql := `SELECT COUNT(*) as count, term @@ -1078,11 +1096,14 @@ func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *dashboards GROUP BY term ORDER BY term` - query.Result = make([]*dashboards.DashboardTagCloudItem, 0) sess := dbSession.SQL(sql, query.OrgID) - err := sess.Find(&query.Result) + err := sess.Find(&queryResult) return err }) + if err != nil { + return nil, err + } + return queryResult, nil } // CountDashboardsInFolder returns a count of all dashboards associated with the diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index e2ea275c414..a63517b83d4 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -8,9 +8,9 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -27,7 +27,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { } t.Run("Testing DB", func(t *testing.T) { var sqlStore *sqlstore.SQLStore - var folder, dashInRoot, childDash *dashboards.Dashboard + var flder, dashInRoot, childDash *dashboards.Dashboard var currentUser user.User var dashboardStore *DashboardStore @@ -38,10 +38,10 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { var err error dashboardStore, err = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) require.NoError(t, err) - folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") + flder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") - childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.ID, false, "prod", "webapp") - insertTestDashboard(t, dashboardStore, "test dash 45", 1, folder.ID, false, "prod") + childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, flder.ID, false, "prod", "webapp") + insertTestDashboard(t, dashboardStore, "test dash 45", 1, flder.ID, false, "prod") currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) } @@ -50,33 +50,33 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and no acls are set", func(t *testing.T) { t.Run("should return all dashboards", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, - DashboardIds: []int64{folder.ID, dashInRoot.ID}, + DashboardIds: []int64{flder.ID, dashInRoot.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) - require.Equal(t, query.Result[0].ID, folder.ID) + require.Equal(t, query.Result[0].ID, flder.ID) require.Equal(t, query.Result[1].ID, dashInRoot.ID) }) }) t.Run("and acl is set for dashboard folder", func(t *testing.T) { var otherUser int64 = 999 - err := updateDashboardACL(t, dashboardStore, folder.ID, dashboards.DashboardACL{ - DashboardID: folder.ID, + err := updateDashboardACL(t, dashboardStore, flder.ID, dashboards.DashboardACL{ + DashboardID: flder.ID, OrgID: 1, UserID: otherUser, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("should not return folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, - OrgId: 1, DashboardIds: []int64{folder.ID, dashInRoot.ID}, + OrgId: 1, DashboardIds: []int64{flder.ID, dashInRoot.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) @@ -86,40 +86,40 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("when the user is given permission", func(t *testing.T) { - err := updateDashboardACL(t, dashboardStore, folder.ID, dashboards.DashboardACL{ - DashboardID: folder.ID, OrgID: 1, UserID: currentUser.ID, Permission: models.PERMISSION_EDIT, + err := updateDashboardACL(t, dashboardStore, flder.ID, dashboards.DashboardACL{ + DashboardID: flder.ID, OrgID: 1, UserID: currentUser.ID, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("should be able to access folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, - DashboardIds: []int64{folder.ID, dashInRoot.ID}, + DashboardIds: []int64{flder.ID, dashInRoot.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) - require.Equal(t, query.Result[0].ID, folder.ID) + require.Equal(t, query.Result[0].ID, flder.ID) require.Equal(t, query.Result[1].ID, dashInRoot.ID) }) }) t.Run("when the user is an admin", func(t *testing.T) { t.Run("should be able to access folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleAdmin, }, OrgId: 1, - DashboardIds: []int64{folder.ID, dashInRoot.ID}, + DashboardIds: []int64{flder.ID, dashInRoot.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) - require.Equal(t, query.Result[0].ID, folder.ID) + require.Equal(t, query.Result[0].ID, flder.ID) require.Equal(t, query.Result[1].ID, dashInRoot.ID) }) }) @@ -127,16 +127,16 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and acl is set for dashboard child and folder has all permissions removed", func(t *testing.T) { var otherUser int64 = 999 - err := updateDashboardACL(t, dashboardStore, folder.ID) + err := updateDashboardACL(t, dashboardStore, flder.ID) require.NoError(t, err) err = updateDashboardACL(t, dashboardStore, childDash.ID, dashboards.DashboardACL{ - DashboardID: folder.ID, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, + DashboardID: flder.ID, OrgID: 1, UserID: otherUser, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("should not return folder or child", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ - SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{folder.ID, childDash.ID, dashInRoot.ID}, + query := &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{flder.ID, childDash.ID, dashInRoot.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) @@ -146,12 +146,12 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("when the user is given permission to child", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, childDash.ID, dashboards.DashboardACL{ - DashboardID: childDash.ID, OrgID: 1, UserID: currentUser.ID, Permission: models.PERMISSION_EDIT, + DashboardID: childDash.ID, OrgID: 1, UserID: currentUser.ID, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("should be able to search for child dashboard but not folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{folder.ID, childDash.ID, dashInRoot.ID}} + query := &dashboards.FindPersistedDashboardsQuery{SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{flder.ID, childDash.ID, dashInRoot.ID}} err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -162,19 +162,19 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("when the user is an admin", func(t *testing.T) { t.Run("should be able to search for child dash and folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleAdmin, }, OrgId: 1, - DashboardIds: []int64{folder.ID, dashInRoot.ID, childDash.ID}, + DashboardIds: []int64{flder.ID, dashInRoot.ID, childDash.ID}, } err := testSearchDashboards(dashboardStore, query) require.NoError(t, err) require.Equal(t, len(query.Result), 3) - require.Equal(t, query.Result[0].ID, folder.ID) + require.Equal(t, query.Result[0].ID, flder.ID) require.Equal(t, query.Result[1].ID, childDash.ID) require.Equal(t, query.Result[2].ID, dashInRoot.ID) }) @@ -205,7 +205,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2() t.Run("and one folder is expanded, the other collapsed", func(t *testing.T) { t.Run("should return dashboards in root and expanded folder", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ FolderIds: []int64{ rootFolderId, folder1.ID}, SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer, @@ -225,7 +225,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and acl is set for one dashboard folder", func(t *testing.T) { const otherUser int64 = 999 err := updateDashboardACL(t, dashboardStore, folder1.ID, dashboards.DashboardACL{ - DashboardID: folder1.ID, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, + DashboardID: folder1.ID, OrgID: 1, UserID: otherUser, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) @@ -233,7 +233,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { moveDashboard(t, dashboardStore, 1, childDash2.Data, folder1.ID) t.Run("should not return folder with acl or its children", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{folder1.ID, childDash1.ID, childDash2.ID, dashInRoot.ID}, @@ -249,7 +249,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID) t.Run("should return folder without acl and its children", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{folder2.ID, childDash1.ID, childDash2.ID, dashInRoot.ID}, @@ -266,14 +266,14 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and a dashboard with an acl is moved to the folder without an acl", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, childDash1.ID, dashboards.DashboardACL{ - DashboardID: childDash1.ID, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, + DashboardID: childDash1.ID, OrgID: 1, UserID: otherUser, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID) t.Run("should return folder without acl but not the dashboard with acl", func(t *testing.T) { - query := &models.FindPersistedDashboardsQuery{ + query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{UserID: currentUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, OrgId: 1, DashboardIds: []int64{folder2.ID, childDash1.ID, childDash2.ID, dashInRoot.ID}, @@ -312,10 +312,10 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup3() t.Run("Admin users", func(t *testing.T) { t.Run("Should have write access to all dashboard folders in their org", func(t *testing.T) { - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, SignedInUser: &user.SignedInUser{UserID: adminUser.ID, OrgRole: org.RoleAdmin, OrgID: 1}, - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, Type: "dash-folder", } @@ -328,29 +328,29 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("should have edit permission in folders", func(t *testing.T) { - query := &models.HasEditPermissionInFoldersQuery{ + query := &folder.HasEditPermissionInFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: adminUser.ID, OrgID: 1, OrgRole: org.RoleAdmin}, } - err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) + queryResult, err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) require.NoError(t, err) - require.True(t, query.Result) + require.True(t, queryResult) }) t.Run("should have admin permission in folders", func(t *testing.T) { - query := &models.HasAdminPermissionInDashboardsOrFoldersQuery{ + query := &folder.HasAdminPermissionInDashboardsOrFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: adminUser.ID, OrgID: 1, OrgRole: org.RoleAdmin}, } - err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) + queryResult, err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) require.NoError(t, err) - require.True(t, query.Result) + require.True(t, queryResult) }) }) t.Run("Editor users", func(t *testing.T) { - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, SignedInUser: &user.SignedInUser{UserID: editorUser.ID, OrgRole: org.RoleEditor, OrgID: 1}, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, } t.Run("Should have write access to all dashboard folders with default ACL", func(t *testing.T) { @@ -364,7 +364,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("Should have write access to one dashboard folder if default role changed to view for one folder", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, folder1.ID, dashboards.DashboardACL{ - DashboardID: folder1.ID, OrgID: 1, UserID: editorUser.ID, Permission: models.PERMISSION_VIEW, + DashboardID: folder1.ID, OrgID: 1, UserID: editorUser.ID, Permission: dashboards.PERMISSION_VIEW, }) require.NoError(t, err) @@ -376,29 +376,29 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("should have edit permission in folders", func(t *testing.T) { - query := &models.HasEditPermissionInFoldersQuery{ + query := &folder.HasEditPermissionInFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: editorUser.ID, OrgID: 1, OrgRole: org.RoleEditor}, } - err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) + queryResult, err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) go require.NoError(t, err) - require.True(t, query.Result) + require.True(t, queryResult) }) t.Run("should not have admin permission in folders", func(t *testing.T) { - query := &models.HasAdminPermissionInDashboardsOrFoldersQuery{ + query := &folder.HasAdminPermissionInDashboardsOrFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: adminUser.ID, OrgID: 1, OrgRole: org.RoleEditor}, } - err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) + queryResult, err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) require.NoError(t, err) - require.False(t, query.Result) + require.False(t, queryResult) }) }) t.Run("Viewer users", func(t *testing.T) { - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, SignedInUser: &user.SignedInUser{UserID: viewerUser.ID, OrgRole: org.RoleViewer, OrgID: 1}, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, } t.Run("Should have no write access to any dashboard folders with default ACL", func(t *testing.T) { @@ -410,7 +410,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("Should be able to get one dashboard folder if default role changed to edit for one folder", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, folder1.ID, dashboards.DashboardACL{ - DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: models.PERMISSION_EDIT, + DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) @@ -424,52 +424,52 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("should not have edit permission in folders", func(t *testing.T) { setup3() - query := &models.HasEditPermissionInFoldersQuery{ + query := &folder.HasEditPermissionInFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: viewerUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, } - err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) + queryResult, err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) go require.NoError(t, err) - require.False(t, query.Result) + require.False(t, queryResult) }) t.Run("should not have admin permission in folders", func(t *testing.T) { - query := &models.HasAdminPermissionInDashboardsOrFoldersQuery{ + query := &folder.HasAdminPermissionInDashboardsOrFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: adminUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, } - err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) + queryResult, err := dashboardStore.HasAdminPermissionInDashboardsOrFolders(context.Background(), query) require.NoError(t, err) - require.False(t, query.Result) + require.False(t, queryResult) }) t.Run("and admin permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, folder1.ID, dashboards.DashboardACL{ - DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: models.PERMISSION_ADMIN, + DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: dashboards.PERMISSION_ADMIN, }) require.NoError(t, err) t.Run("should have edit permission in folders", func(t *testing.T) { - query := &models.HasEditPermissionInFoldersQuery{ + query := &folder.HasEditPermissionInFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: viewerUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, } - err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) + queryResult, err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) go require.NoError(t, err) - require.True(t, query.Result) + require.True(t, queryResult) }) }) t.Run("and edit permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) { err := updateDashboardACL(t, dashboardStore, folder1.ID, dashboards.DashboardACL{ - DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: models.PERMISSION_EDIT, + DashboardID: folder1.ID, OrgID: 1, UserID: viewerUser.ID, Permission: dashboards.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("should have edit permission in folders", func(t *testing.T) { - query := &models.HasEditPermissionInFoldersQuery{ + query := &folder.HasEditPermissionInFoldersQuery{ SignedInUser: &user.SignedInUser{UserID: viewerUser.ID, OrgID: 1, OrgRole: org.RoleViewer}, } - err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) + queryResult, err := dashboardStore.HasEditPermissionInFolders(context.Background(), query) go require.NoError(t, err) - require.True(t, query.Result) + require.True(t, queryResult) }) }) }) diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index fecc9cc74b9..d4cf0e47f74 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -81,19 +81,19 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { require.Nil(t, err) query := &dashboards.GetDashboardsQuery{DashboardIDs: []int64{anotherDash.ID}} - err = dashboardStore.GetDashboards(context.Background(), query) + queryResult, err := dashboardStore.GetDashboards(context.Background(), query) require.Nil(t, err) - require.NotNil(t, query.Result) + require.NotNil(t, queryResult) deleteCmd := &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: []string{"default"}} require.Nil(t, dashboardStore.DeleteOrphanedProvisionedDashboards(context.Background(), deleteCmd)) query = &dashboards.GetDashboardsQuery{DashboardIDs: []int64{dash.ID, anotherDash.ID}} - err = dashboardStore.GetDashboards(context.Background(), query) + queryResult, err = dashboardStore.GetDashboards(context.Background(), query) require.Nil(t, err) - require.Equal(t, 1, len(query.Result)) - require.Equal(t, dashId, query.Result[0].ID) + require.Equal(t, 1, len(queryResult)) + require.Equal(t, dashId, queryResult[0].ID) }) t.Run("Can query for provisioned dashboards", func(t *testing.T) { diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 8ca8b5b6010..7f643a8fc78 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -80,14 +80,14 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { OrgID: 1, } - _, err := dashboardStore.GetDashboard(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) require.NoError(t, err) - require.Equal(t, query.Result.Title, "test dash 23") - require.Equal(t, query.Result.Slug, "test-dash-23") - require.Equal(t, query.Result.ID, savedDash.ID) - require.Equal(t, query.Result.UID, savedDash.UID) - require.False(t, query.Result.IsFolder) + require.Equal(t, queryResult.Title, "test dash 23") + require.Equal(t, queryResult.Slug, "test-dash-23") + require.Equal(t, queryResult.ID, savedDash.ID) + require.Equal(t, queryResult.UID, savedDash.UID) + require.False(t, queryResult.IsFolder) }) t.Run("Should be able to get dashboard by slug", func(t *testing.T) { @@ -97,14 +97,14 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { OrgID: 1, } - _, err := dashboardStore.GetDashboard(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) require.NoError(t, err) - require.Equal(t, query.Result.Title, "test dash 23") - require.Equal(t, query.Result.Slug, "test-dash-23") - require.Equal(t, query.Result.ID, savedDash.ID) - require.Equal(t, query.Result.UID, savedDash.UID) - require.False(t, query.Result.IsFolder) + require.Equal(t, queryResult.Title, "test dash 23") + require.Equal(t, queryResult.Slug, "test-dash-23") + require.Equal(t, queryResult.ID, savedDash.ID) + require.Equal(t, queryResult.UID, savedDash.UID) + require.False(t, queryResult.IsFolder) }) t.Run("Should be able to get dashboard by uid", func(t *testing.T) { @@ -114,22 +114,22 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { OrgID: 1, } - _, err := dashboardStore.GetDashboard(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) require.NoError(t, err) - require.Equal(t, query.Result.Title, "test dash 23") - require.Equal(t, query.Result.Slug, "test-dash-23") - require.Equal(t, query.Result.ID, savedDash.ID) - require.Equal(t, query.Result.UID, savedDash.UID) - require.False(t, query.Result.IsFolder) + require.Equal(t, queryResult.Title, "test dash 23") + require.Equal(t, queryResult.Slug, "test-dash-23") + require.Equal(t, queryResult.ID, savedDash.ID) + require.Equal(t, queryResult.UID, savedDash.UID) + require.False(t, queryResult.IsFolder) }) t.Run("Should be able to get a dashboard UID by ID", func(t *testing.T) { setup() query := dashboards.GetDashboardRefByIDQuery{ID: savedDash.ID} - err := dashboardStore.GetDashboardUIDByID(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardUIDByID(context.Background(), &query) require.NoError(t, err) - require.Equal(t, query.Result.UID, savedDash.UID) + require.Equal(t, queryResult.UID, savedDash.UID) }) t.Run("Shouldn't be able to get a dashboard with just an OrgID", func(t *testing.T) { @@ -145,14 +145,14 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to get dashboards by IDs & UIDs", func(t *testing.T) { setup() query := dashboards.GetDashboardsQuery{DashboardIDs: []int64{savedDash.ID, savedDash2.ID}} - err := dashboardStore.GetDashboards(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboards(context.Background(), &query) require.NoError(t, err) - assert.Equal(t, len(query.Result), 2) + assert.Equal(t, len(queryResult), 2) query = dashboards.GetDashboardsQuery{DashboardUIDs: []string{savedDash.UID, savedDash2.UID}} - err = dashboardStore.GetDashboards(context.Background(), &query) + queryResult, err = dashboardStore.GetDashboards(context.Background(), &query) require.NoError(t, err) - assert.Equal(t, len(query.Result), 2) + assert.Equal(t, len(queryResult), 2) }) t.Run("Should be able to delete dashboard", func(t *testing.T) { @@ -220,13 +220,13 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { OrgID: 1, } - _, err = dashboardStore.GetDashboard(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboard(context.Background(), &query) require.NoError(t, err) - require.Equal(t, query.Result.FolderID, int64(0)) - require.Equal(t, query.Result.CreatedBy, savedDash.CreatedBy) - require.WithinDuration(t, query.Result.Created, savedDash.Created, 3*time.Second) - require.Equal(t, query.Result.UpdatedBy, int64(100)) - require.False(t, query.Result.Updated.IsZero()) + require.Equal(t, queryResult.FolderID, int64(0)) + require.Equal(t, queryResult.CreatedBy, savedDash.CreatedBy) + require.WithinDuration(t, queryResult.Created, savedDash.Created, 3*time.Second) + require.Equal(t, queryResult.UpdatedBy, int64(100)) + require.False(t, queryResult.Updated.IsZero()) }) t.Run("Should be able to delete empty folder", func(t *testing.T) { @@ -308,9 +308,9 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { query := dashboards.GetDashboardsQuery{ DashboardIDs: []int64{savedFolder.ID, savedDash.ID}, } - err = dashboardStore.GetDashboards(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboards(context.Background(), &query) require.NoError(t, err) - require.Equal(t, len(query.Result), 0) + require.Equal(t, len(queryResult), 0) pubdashConfig, err = publicDashboardStore.FindByAccessToken(context.Background(), "an-access-token") require.Nil(t, err) @@ -323,7 +323,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { err := dashboardStore.DeleteDashboard(context.Background(), deleteCmd) require.NoError(t, err) - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, FolderIds: []int64{savedFolder.ID}, SignedInUser: &user.SignedInUser{}, @@ -382,15 +382,15 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { setup() query := dashboards.GetDashboardTagsQuery{OrgID: 1} - err := dashboardStore.GetDashboardTags(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardTags(context.Background(), &query) require.NoError(t, err) - require.Equal(t, len(query.Result), 2) + require.Equal(t, len(queryResult), 2) }) t.Run("Should be able to find dashboard folder", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ Title: "1 test dash folder", OrgId: 1, SignedInUser: &user.SignedInUser{ @@ -414,7 +414,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to limit find results", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, Limit: 1, SignedInUser: &user.SignedInUser{ @@ -435,7 +435,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to find results beyond limit using paging", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, Limit: 1, Page: 2, @@ -460,7 +460,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to filter by tag and type", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, Type: "dash-db", Tags: []string{"prod"}, @@ -482,7 +482,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to find a dashboard folder's children", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ OrgId: 1, FolderIds: []int64{savedFolder.ID}, SignedInUser: &user.SignedInUser{ @@ -509,7 +509,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should be able to find dashboards by ids", func(t *testing.T) { setup() - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ DashboardIds: []int64{savedDash.ID, savedDash2.ID}, SignedInUser: &user.SignedInUser{ OrgID: 1, @@ -547,7 +547,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { }) require.NoError(t, err) - query := models.FindPersistedDashboardsQuery{ + query := dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ UserID: 10, OrgID: 1, @@ -603,9 +603,9 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test OrgID: 1, } - err = dashboardStore.GetDashboardsByPluginID(context.Background(), &query) + queryResult, err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query) require.NoError(t, err) - require.Equal(t, len(query.Result), 2) + require.Equal(t, len(queryResult), 2) } func TestIntegrationDashboard_SortingOptions(t *testing.T) { @@ -623,7 +623,7 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) assert.NotZero(t, dashA.ID) assert.Less(t, dashB.ID, dashA.ID) - qNoSort := &models.FindPersistedDashboardsQuery{ + qNoSort := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ OrgID: 1, UserID: 1, @@ -639,7 +639,7 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { assert.Equal(t, dashA.ID, results[0].ID) assert.Equal(t, dashB.ID, results[1].ID) - qSort := &models.FindPersistedDashboardsQuery{ + qSort := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ OrgID: 1, UserID: 1, @@ -673,7 +673,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { require.NoError(t, err) insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) - qNoFilter := &models.FindPersistedDashboardsQuery{ + qNoFilter := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ OrgID: 1, UserID: 1, @@ -687,7 +687,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { require.NoError(t, err) require.Len(t, results, 2) - qFilter := &models.FindPersistedDashboardsQuery{ + qFilter := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: &user.SignedInUser{ OrgID: 1, UserID: 1, @@ -840,7 +840,7 @@ func updateDashboardACL(t *testing.T, dashboardStore *DashboardStore, dashboardI // testSearchDashboards is a (near) copy of the dashboard service // SearchDashboards, which is a wrapper around FindDashboards. -func testSearchDashboards(d *DashboardStore, query *models.FindPersistedDashboardsQuery) error { +func testSearchDashboards(d *DashboardStore, query *dashboards.FindPersistedDashboardsQuery) error { res, err := d.FindDashboards(context.Background(), query) if err != nil { return err @@ -849,7 +849,7 @@ func testSearchDashboards(d *DashboardStore, query *models.FindPersistedDashboar return nil } -func makeQueryResult(query *models.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) { +func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) { query.Result = make([]*models.Hit, 0) hits := make(map[int64]*models.Hit) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 0b74c017793..b67cd20c33c 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -198,8 +198,6 @@ type SaveDashboardCommand struct { IsFolder bool `json:"isFolder"` UpdatedAt time.Time - - Result *Dashboard `json:"-"` } type ValidateDashboardCommand struct { @@ -209,7 +207,6 @@ type ValidateDashboardCommand struct { type TrimDashboardCommand struct { Dashboard *simplejson.Json `json:"dashboard" binding:"Required"` Meta *simplejson.Json `json:"meta"` - Result *Dashboard `json:"-"` } type DashboardProvisioning struct { @@ -240,8 +237,6 @@ type GetDashboardQuery struct { ID int64 // optional if slug is set UID string // optional if slug is set OrgID int64 - - Result *Dashboard } type DashboardTagCloudItem struct { @@ -250,33 +245,18 @@ type DashboardTagCloudItem struct { } type GetDashboardTagsQuery struct { - OrgID int64 - Result []*DashboardTagCloudItem + OrgID int64 } type GetDashboardsQuery struct { DashboardIDs []int64 DashboardUIDs []string OrgID int64 - Result []*Dashboard } type GetDashboardsByPluginIDQuery struct { OrgID int64 PluginID string - Result []*Dashboard -} - -type GetDashboardSlugByIdQuery struct { - ID int64 - Result string -} - -type GetDashboardsBySlugQuery struct { - OrgID int64 - Slug string - - Result []*Dashboard } type DashboardRef struct { @@ -285,8 +265,7 @@ type DashboardRef struct { } type GetDashboardRefByIDQuery struct { - ID int64 - Result *DashboardRef + ID int64 } type SaveDashboardDTO struct { @@ -336,7 +315,7 @@ func FromDashboard(dash *Dashboard) *folder.Folder { UID: dash.UID, Title: dash.Title, HasACL: dash.HasACL, - Url: GetFolderURL(dash.UID, dash.Slug), + URL: GetFolderURL(dash.UID, dash.Slug), Version: dash.Version, Created: dash.Created, CreatedBy: dash.CreatedBy, @@ -358,7 +337,7 @@ type DashboardACL struct { UserID int64 `xorm:"user_id"` TeamID int64 `xorm:"team_id"` Role *org.RoleType // pointer to be nullable - Permission models.PermissionType + Permission PermissionType Created time.Time Updated time.Time @@ -374,23 +353,23 @@ type DashboardACLInfoDTO struct { Created time.Time `json:"created"` Updated time.Time `json:"updated"` - UserID int64 `json:"userId" xorm:"user_id"` - UserLogin string `json:"userLogin"` - UserEmail string `json:"userEmail"` - UserAvatarURL string `json:"userAvatarUrl" xorm:"user_avatar_url"` - TeamID int64 `json:"teamId" xorm:"team_id"` - TeamEmail string `json:"teamEmail"` - TeamAvatarURL string `json:"teamAvatarUrl" xorm:"team_avatar_url"` - Team string `json:"team"` - Role *org.RoleType `json:"role,omitempty"` - Permission models.PermissionType `json:"permission"` - PermissionName string `json:"permissionName"` - UID string `json:"uid" xorm:"uid"` - Title string `json:"title"` - Slug string `json:"slug"` - IsFolder bool `json:"isFolder"` - URL string `json:"url" xorm:"url"` - Inherited bool `json:"inherited"` + UserID int64 `json:"userId" xorm:"user_id"` + UserLogin string `json:"userLogin"` + UserEmail string `json:"userEmail"` + UserAvatarURL string `json:"userAvatarUrl" xorm:"user_avatar_url"` + TeamID int64 `json:"teamId" xorm:"team_id"` + TeamEmail string `json:"teamEmail"` + TeamAvatarURL string `json:"teamAvatarUrl" xorm:"team_avatar_url"` + Team string `json:"team"` + Role *org.RoleType `json:"role,omitempty"` + Permission PermissionType `json:"permission"` + PermissionName string `json:"permissionName"` + UID string `json:"uid" xorm:"uid"` + Title string `json:"title"` + Slug string `json:"slug"` + IsFolder bool `json:"isFolder"` + URL string `json:"url" xorm:"url"` + Inherited bool `json:"inherited"` } func (dto *DashboardACLInfoDTO) hasSameRoleAs(other *DashboardACLInfoDTO) bool { @@ -418,5 +397,24 @@ func (dto *DashboardACLInfoDTO) IsDuplicateOf(other *DashboardACLInfoDTO) bool { type GetDashboardACLInfoListQuery struct { DashboardID int64 OrgID int64 - Result []*DashboardACLInfoDTO +} + +type FindPersistedDashboardsQuery struct { + Title string + OrgId int64 + SignedInUser *user.SignedInUser + IsStarred bool + DashboardIds []int64 + DashboardUIDs []string + Type string + FolderIds []int64 + Tags []string + Limit int64 + Page int64 + Permission PermissionType + Sort models.SortOption + + Filters []interface{} + + Result models.HitList } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index ca29dd390c8..b4098316e02 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -30,7 +31,9 @@ var ( {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeFoldersAll}, } // DashboardServiceImpl implements the DashboardService interface - _ dashboards.DashboardService = (*DashboardServiceImpl)(nil) + _ dashboards.DashboardService = (*DashboardServiceImpl)(nil) + _ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil) + _ dashboards.PluginService = (*DashboardServiceImpl)(nil) ) type DashboardServiceImpl struct { @@ -45,13 +48,15 @@ type DashboardServiceImpl struct { ac accesscontrol.AccessControl } +// This is the uber service that implements a three smaller services func ProvideDashboardService( cfg *setting.Cfg, dashboardStore dashboards.Store, folderStore dashboards.FolderStore, dashAlertExtractor alerting.DashAlertExtractor, features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, ac accesscontrol.AccessControl, + folderSvc folder.Service, ) *DashboardServiceImpl { - ac.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(dashboardStore, folderStore)) - ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashboardStore, folderStore)) + ac.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(dashboardStore, folderStore, folderSvc)) + ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashboardStore, folderStore, folderSvc)) return &DashboardServiceImpl{ cfg: cfg, @@ -396,7 +401,7 @@ func (dr *DashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, OrgID: orgID, DashboardID: dashboardID, UserID: userID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: time.Now(), Updated: time.Now(), }, @@ -408,7 +413,7 @@ func (dr *DashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, OrgID: orgID, DashboardID: dashboardID, Role: &rtEditor, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, Created: time.Now(), Updated: time.Now(), }, @@ -416,7 +421,7 @@ func (dr *DashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, OrgID: orgID, DashboardID: dashboardID, Role: &rtViewer, - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, Created: time.Now(), Updated: time.Now(), }, @@ -482,7 +487,7 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) } -func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) error { +func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { return dr.dashboardStore.GetDashboardsByPluginID(ctx, query) } @@ -492,14 +497,14 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto * var permissions []accesscontrol.SetResourcePermissionCommand if !provisioned && dto.User.IsRealUser() && !dto.User.IsAnonymous { permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{ - UserID: dto.User.UserID, Permission: models.PERMISSION_ADMIN.String(), + UserID: dto.User.UserID, Permission: dashboards.PERMISSION_ADMIN.String(), }) } if !inFolder { permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: models.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: models.PERMISSION_VIEW.String()}, + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, }...) } @@ -521,24 +526,23 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto * return nil } -func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) error { - _, err := dr.dashboardStore.GetDashboard(ctx, query) - return err +func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { + return dr.dashboardStore.GetDashboard(ctx, query) } -func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) error { +func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { return dr.dashboardStore.GetDashboardUIDByID(ctx, query) } -func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) error { +func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { return dr.dashboardStore.GetDashboards(ctx, query) } -func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { +func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { return dr.dashboardStore.FindDashboards(ctx, query) } -func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) error { +func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) error { res, err := dr.FindDashboards(ctx, query) if err != nil { return err @@ -560,7 +564,7 @@ func getHitType(item dashboards.DashboardSearchProjection) models.HitType { return hitType } -func makeQueryResult(query *models.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) { +func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashboards.DashboardSearchProjection) { query.Result = make([]*models.Hit, 0) hits := make(map[int64]*models.Hit) @@ -598,19 +602,19 @@ func makeQueryResult(query *models.FindPersistedDashboardsQuery, res []dashboard } } -func (dr *DashboardServiceImpl) GetDashboardACLInfoList(ctx context.Context, query *dashboards.GetDashboardACLInfoListQuery) error { +func (dr *DashboardServiceImpl) GetDashboardACLInfoList(ctx context.Context, query *dashboards.GetDashboardACLInfoListQuery) ([]*dashboards.DashboardACLInfoDTO, error) { return dr.dashboardStore.GetDashboardACLInfoList(ctx, query) } -func (dr *DashboardServiceImpl) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { +func (dr *DashboardServiceImpl) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { return dr.dashboardStore.HasAdminPermissionInDashboardsOrFolders(ctx, query) } -func (dr *DashboardServiceImpl) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { +func (dr *DashboardServiceImpl) HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) { return dr.dashboardStore.HasEditPermissionInFolders(ctx, query) } -func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) error { +func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { return dr.dashboardStore.GetDashboardTags(ctx, query) } diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 03264673f95..5a3c59233c0 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -831,6 +832,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), + foldertest.NewFakeService(), ) guardian.InitLegacyGuardian(sqlStore, service, &teamtest.FakeService{}) @@ -890,6 +892,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), + foldertest.NewFakeService(), ) res, err := service.SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) @@ -911,6 +914,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), + foldertest.NewFakeService(), ) _, err = service.SaveDashboard(context.Background(), &dto, false) return err @@ -950,6 +954,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), + foldertest.NewFakeService(), ) res, err := service.SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) @@ -990,6 +995,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), + foldertest.NewFakeService(), ) res, err := service.SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 3617e62debe..2f3b80a0be4 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -260,22 +260,22 @@ func TestDashboardService(t *testing.T) { t.Run("When org user is deleted", func(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} - fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil) + fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil, nil) t.Run("Should remove dependent permissions for deleted org user", func(t *testing.T) { - permQuery := &dashboards.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: 1, Result: nil} + permQuery := &dashboards.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: 1} - err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) + permQueryResult, err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) require.NoError(t, err) - require.Equal(t, len(permQuery.Result), 0) + require.Equal(t, len(permQueryResult), 0) }) t.Run("Should not remove dashboard permissions for same user in another org", func(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} - fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil) + fakeStore.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(nil, nil) permQuery := &dashboards.GetDashboardACLInfoListQuery{DashboardID: 2, OrgID: 3} - err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) + _, err := fakeStore.GetDashboardACLInfoList(context.Background(), permQuery) require.NoError(t, err) }) }) diff --git a/pkg/services/dashboards/service/service.go b/pkg/services/dashboards/service/service.go new file mode 100644 index 00000000000..b5989c2d94c --- /dev/null +++ b/pkg/services/dashboards/service/service.go @@ -0,0 +1,35 @@ +package service + +import ( + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/store/k8saccess" +) + +func ProvideSimpleDashboardService( + features featuremgmt.FeatureToggles, + svc *DashboardServiceImpl, + k8s k8saccess.K8SAccess, + store entity.EntityStoreServer, +) dashboards.DashboardService { + if features.IsEnabled(featuremgmt.FlagK8sDashboards) { + if k8s.GetSystemClient() == nil { + panic("k8s dashboards requires the k8s client registered") + } + return k8saccess.NewDashboardService(svc, store) + } + return svc +} + +func ProvideDashboardProvisioningService( + features featuremgmt.FeatureToggles, orig *DashboardServiceImpl, +) dashboards.DashboardProvisioningService { + return orig +} + +func ProvideDashboardPluginService( + features featuremgmt.FeatureToggles, orig *DashboardServiceImpl, +) dashboards.PluginService { + return orig +} diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 361b376ece6..f32268a86ce 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,15 +1,14 @@ -// Code generated by mockery v2.15.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package dashboards import ( context "context" - alertingmodels "github.com/grafana/grafana/pkg/services/alerting/models" - + folder "github.com/grafana/grafana/pkg/services/folder" mock "github.com/stretchr/testify/mock" - models "github.com/grafana/grafana/pkg/models" + models "github.com/grafana/grafana/pkg/services/alerting/models" quota "github.com/grafana/grafana/pkg/services/quota" ) @@ -106,11 +105,11 @@ func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Co } // FindDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { +func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { ret := _m.Called(ctx, query) var r0 []DashboardSearchProjection - if rf, ok := ret.Get(0).(func(context.Context, *models.FindPersistedDashboardsQuery) []DashboardSearchProjection); ok { + if rf, ok := ret.Get(0).(func(context.Context, *FindPersistedDashboardsQuery) []DashboardSearchProjection); ok { r0 = rf(ctx, query) } else { if ret.Get(0) != nil { @@ -119,7 +118,7 @@ func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *models. } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, *models.FindPersistedDashboardsQuery) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *FindPersistedDashboardsQuery) error); ok { r1 = rf(ctx, query) } else { r1 = ret.Error(1) @@ -152,73 +151,118 @@ func (_m *FakeDashboardStore) GetDashboard(ctx context.Context, query *GetDashbo } // GetDashboardACLInfoList provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) error { +func (_m *FakeDashboardStore) GetDashboardACLInfoList(ctx context.Context, query *GetDashboardACLInfoListQuery) ([]*DashboardACLInfoDTO, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardACLInfoListQuery) error); ok { + var r0 []*DashboardACLInfoDTO + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardACLInfoListQuery) []*DashboardACLInfoDTO); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*DashboardACLInfoDTO) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardACLInfoListQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardTags provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) error { +func (_m *FakeDashboardStore) GetDashboardTags(ctx context.Context, query *GetDashboardTagsQuery) ([]*DashboardTagCloudItem, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardTagsQuery) error); ok { + var r0 []*DashboardTagCloudItem + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardTagsQuery) []*DashboardTagCloudItem); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*DashboardTagCloudItem) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardTagsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardUIDByID provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) error { +func (_m *FakeDashboardStore) GetDashboardUIDByID(ctx context.Context, query *GetDashboardRefByIDQuery) (*DashboardRef, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) error); ok { + var r0 *DashboardRef + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardRefByIDQuery) *DashboardRef); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*DashboardRef) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardRefByIDQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboards provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboards(ctx context.Context, query *GetDashboardsQuery) error { +func (_m *FakeDashboardStore) GetDashboards(ctx context.Context, query *GetDashboardsQuery) ([]*Dashboard, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) error); ok { + var r0 []*Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsQuery) []*Dashboard); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDashboardsByPluginID provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) error { +func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query *GetDashboardsByPluginIDQuery) ([]*Dashboard, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsByPluginIDQuery) error); ok { + var r0 []*Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardsByPluginIDQuery) []*Dashboard); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardsByPluginIDQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetProvisionedDashboardData provides a mock function with given fields: ctx, name @@ -291,39 +335,53 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(ctx context.Conte } // HasAdminPermissionInDashboardsOrFolders provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { +func (_m *FakeDashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.HasAdminPermissionInDashboardsOrFoldersQuery) error); ok { + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, *folder.HasAdminPermissionInDashboardsOrFoldersQuery) bool); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(bool) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *folder.HasAdminPermissionInDashboardsOrFoldersQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // HasEditPermissionInFolders provides a mock function with given fields: ctx, query -func (_m *FakeDashboardStore) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { +func (_m *FakeDashboardStore) HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) { ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.HasEditPermissionInFoldersQuery) error); ok { + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, *folder.HasEditPermissionInFoldersQuery) bool); ok { r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(bool) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *folder.HasEditPermissionInFoldersQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // SaveAlerts provides a mock function with given fields: ctx, dashID, alerts -func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*alertingmodels.Alert) error { +func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { ret := _m.Called(ctx, dashID, alerts) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64, []*alertingmodels.Alert) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.Alert) error); ok { r0 = rf(ctx, dashID, alerts) } else { r0 = ret.Error(0) diff --git a/pkg/services/dashboardsnapshots/database/database.go b/pkg/services/dashboardsnapshots/database/database.go index 443f1ea9077..dab53376535 100644 --- a/pkg/services/dashboardsnapshots/database/database.go +++ b/pkg/services/dashboardsnapshots/database/database.go @@ -15,13 +15,14 @@ import ( type DashboardSnapshotStore struct { store db.DB log log.Logger + cfg *setting.Cfg } // DashboardStore implements the Store interface var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil) -func ProvideStore(db db.DB) *DashboardSnapshotStore { - return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store")} +func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore { + return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg} } // DeleteExpiredSnapshots removes snapshots with old expiry dates. @@ -29,7 +30,7 @@ func ProvideStore(db db.DB) *DashboardSnapshotStore { // Snapshot expiry is decided by the user when they share the snapshot. func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error { return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if !setting.SnapShotRemoveExpired { + if !d.cfg.SnapShotRemoveExpired { d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") return nil } @@ -45,8 +46,9 @@ func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd }) } -func (d *DashboardSnapshotStore) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) error { - return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { +func (d *DashboardSnapshotStore) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (*dashboardsnapshots.DashboardSnapshot, error) { + var result *dashboardsnapshots.DashboardSnapshot + err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { var expires = time.Now().Add(time.Hour * 24 * 365 * 50) if cmd.Expires > 0 { expires = time.Now().Add(time.Second * time.Duration(cmd.Expires)) @@ -56,11 +58,11 @@ func (d *DashboardSnapshotStore) CreateDashboardSnapshot(ctx context.Context, cm Name: cmd.Name, Key: cmd.Key, DeleteKey: cmd.DeleteKey, - OrgId: cmd.OrgId, - UserId: cmd.UserId, + OrgID: cmd.OrgID, + UserID: cmd.UserID, External: cmd.External, - ExternalUrl: cmd.ExternalUrl, - ExternalDeleteUrl: cmd.ExternalDeleteUrl, + ExternalURL: cmd.ExternalURL, + ExternalDeleteURL: cmd.ExternalDeleteURL, Dashboard: simplejson.New(), DashboardEncrypted: cmd.DashboardEncrypted, Expires: expires, @@ -68,10 +70,14 @@ func (d *DashboardSnapshotStore) CreateDashboardSnapshot(ctx context.Context, cm Updated: time.Now(), } _, err := sess.Insert(snapshot) - cmd.Result = snapshot + result = snapshot return err }) + if err != nil { + return nil, err + } + return result, nil } func (d *DashboardSnapshotStore) DeleteDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.DeleteDashboardSnapshotCommand) error { @@ -82,8 +88,9 @@ func (d *DashboardSnapshotStore) DeleteDashboardSnapshot(ctx context.Context, cm }) } -func (d *DashboardSnapshotStore) GetDashboardSnapshot(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotQuery) error { - return d.store.WithDbSession(ctx, func(sess *db.Session) error { +func (d *DashboardSnapshotStore) GetDashboardSnapshot(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotQuery) (*dashboardsnapshots.DashboardSnapshot, error) { + var queryResult *dashboardsnapshots.DashboardSnapshot + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { snapshot := dashboardsnapshots.DashboardSnapshot{Key: query.Key, DeleteKey: query.DeleteKey} has, err := sess.Get(&snapshot) @@ -93,15 +100,20 @@ func (d *DashboardSnapshotStore) GetDashboardSnapshot(ctx context.Context, query return dashboardsnapshots.ErrBaseNotFound.Errorf("dashboard snapshot not found") } - query.Result = &snapshot + queryResult = &snapshot return nil }) + if err != nil { + return nil, err + } + return queryResult, nil } // SearchDashboardSnapshots returns a list of all snapshots for admins // for other roles, it returns snapshots created by the user -func (d *DashboardSnapshotStore) SearchDashboardSnapshots(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotsQuery) error { - return d.store.WithDbSession(ctx, func(sess *db.Session) error { +func (d *DashboardSnapshotStore) SearchDashboardSnapshots(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotsQuery) (dashboardsnapshots.DashboardSnapshotsList, error) { + var queryResult dashboardsnapshots.DashboardSnapshotsList + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var snapshots = make(dashboardsnapshots.DashboardSnapshotsList, 0) if query.Limit > 0 { sess.Limit(query.Limit) @@ -115,16 +127,20 @@ func (d *DashboardSnapshotStore) SearchDashboardSnapshots(ctx context.Context, q // admins can see all snapshots, everyone else can only see their own snapshots switch { case query.SignedInUser.OrgRole == org.RoleAdmin: - sess.Where("org_id = ?", query.OrgId) + sess.Where("org_id = ?", query.OrgID) case !query.SignedInUser.IsAnonymous: - sess.Where("org_id = ? AND user_id = ?", query.OrgId, query.SignedInUser.UserID) + sess.Where("org_id = ? AND user_id = ?", query.OrgID, query.SignedInUser.UserID) default: - query.Result = snapshots + queryResult = snapshots return nil } err := sess.Find(&snapshots) - query.Result = snapshots + queryResult = snapshots return err }) + if err != nil { + return dashboardsnapshots.DashboardSnapshotsList{}, err + } + return queryResult, nil } diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go index 5d8d052175a..63f90107a6f 100644 --- a/pkg/services/dashboardsnapshots/database/database_test.go +++ b/pkg/services/dashboardsnapshots/database/database_test.go @@ -23,7 +23,7 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore) + dashStore := ProvideStore(sqlstore, setting.NewCfg()) origSecret := setting.SecretKey setting.SecretKey = "dashboard_snapshot_testing" @@ -43,23 +43,23 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{ Key: "hej", DashboardEncrypted: encryptedDashboard, - UserId: 1000, - OrgId: 1, + UserID: 1000, + OrgID: 1, } - err = dashStore.CreateDashboardSnapshot(context.Background(), &cmd) + result, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) require.NoError(t, err) t.Run("Should be able to get snapshot by key", func(t *testing.T) { query := dashboardsnapshots.GetDashboardSnapshotQuery{Key: "hej"} - err := dashStore.GetDashboardSnapshot(context.Background(), &query) + queryResult, err := dashStore.GetDashboardSnapshot(context.Background(), &query) require.NoError(t, err) - assert.NotNil(t, query.Result) + assert.NotNil(t, queryResult) decryptedDashboard, err := secretsService.Decrypt( context.Background(), - query.Result.DashboardEncrypted, + queryResult.DashboardEncrypted, ) require.NoError(t, err) @@ -71,43 +71,43 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { t.Run("And the user has the admin role", func(t *testing.T) { query := dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleAdmin}, } - err := dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err := dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) t.Run("Should return all the snapshots", func(t *testing.T) { - assert.NotNil(t, query.Result) - assert.Len(t, query.Result, 1) + assert.NotNil(t, queryResult) + assert.Len(t, queryResult, 1) }) }) t.Run("And the user has the editor role and has created a snapshot", func(t *testing.T) { query := dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor, UserID: 1000}, } - err := dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err := dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) t.Run("Should return all the snapshots", func(t *testing.T) { - require.NotNil(t, query.Result) - assert.Len(t, query.Result, 1) + require.NotNil(t, queryResult) + assert.Len(t, queryResult, 1) }) }) t.Run("And the user has the editor role and has not created any snapshot", func(t *testing.T) { query := dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor, UserID: 2}, } - err := dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err := dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) t.Run("Should not return any snapshots", func(t *testing.T) { - require.NotNil(t, query.Result) - assert.Empty(t, query.Result) + require.NotNil(t, queryResult) + assert.Empty(t, queryResult) }) }) @@ -118,29 +118,29 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { Dashboard: simplejson.NewFromAny(map[string]interface{}{ "hello": "mupp", }), - UserId: 0, - OrgId: 1, + UserID: 0, + OrgID: 1, } - err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) + _, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) require.NoError(t, err) t.Run("Should not return any snapshots", func(t *testing.T) { query := dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleEditor, IsAnonymous: true, UserID: 0}, } - err := dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err := dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) - require.NotNil(t, query.Result) - assert.Empty(t, query.Result) + require.NotNil(t, queryResult) + assert.Empty(t, queryResult) }) }) t.Run("Should have encrypted dashboard data", func(t *testing.T) { decryptedDashboard, err := secretsService.Decrypt( context.Background(), - cmd.Result.DashboardEncrypted, + result.DashboardEncrypted, ) require.NoError(t, err) @@ -154,10 +154,10 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore) + dashStore := ProvideStore(sqlstore, setting.NewCfg()) t.Run("Testing dashboard snapshots clean up", func(t *testing.T) { - setting.SnapShotRemoveExpired = true + dashStore.cfg.SnapShotRemoveExpired = true nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000) createTestSnapshot(t, dashStore, "key2", -1200) @@ -167,27 +167,27 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) { require.NoError(t, err) query := dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleAdmin}, } - err = dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err := dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) - assert.Len(t, query.Result, 1) - assert.Equal(t, nonExpiredSnapshot.Key, query.Result[0].Key) + assert.Len(t, queryResult, 1) + assert.Equal(t, nonExpiredSnapshot.Key, queryResult[0].Key) err = dashStore.DeleteExpiredSnapshots(context.Background(), &dashboardsnapshots.DeleteExpiredSnapshotsCommand{}) require.NoError(t, err) query = dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: 1, + OrgID: 1, SignedInUser: &user.SignedInUser{OrgRole: org.RoleAdmin}, } - err = dashStore.SearchDashboardSnapshots(context.Background(), &query) + queryResult, err = dashStore.SearchDashboardSnapshots(context.Background(), &query) require.NoError(t, err) - require.Len(t, query.Result, 1) - require.Equal(t, nonExpiredSnapshot.Key, query.Result[0].Key) + require.Len(t, queryResult, 1) + require.Equal(t, nonExpiredSnapshot.Key, queryResult[0].Key) }) } @@ -198,22 +198,22 @@ func createTestSnapshot(t *testing.T, dashStore *DashboardSnapshotStore, key str Dashboard: simplejson.NewFromAny(map[string]interface{}{ "hello": "mupp", }), - UserId: 1000, - OrgId: 1, + UserID: 1000, + OrgID: 1, Expires: expires, } - err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) + result, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd) require.NoError(t, err) // Set expiry date manually - to be able to create expired snapshots if expires < 0 { expireDate := time.Now().Add(time.Second * time.Duration(expires)) err = dashStore.store.WithDbSession(context.Background(), func(sess *db.Session) error { - _, err := sess.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, cmd.Result.Id) + _, err := sess.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, result.ID) return err }) require.NoError(t, err) } - return cmd.Result + return result } diff --git a/pkg/services/dashboardsnapshots/models.go b/pkg/services/dashboardsnapshots/models.go index b527c6dcfc0..e145113b73c 100644 --- a/pkg/services/dashboardsnapshots/models.go +++ b/pkg/services/dashboardsnapshots/models.go @@ -9,15 +9,15 @@ import ( // DashboardSnapshot model type DashboardSnapshot struct { - Id int64 + ID int64 `xorm:"pk autoincr 'id'"` Name string Key string DeleteKey string - OrgId int64 - UserId int64 + OrgID int64 `xorm:"org_id"` + UserID int64 `xorm:"user_id"` External bool - ExternalUrl string - ExternalDeleteUrl string + ExternalURL string `xorm:"external_url"` + ExternalDeleteURL string `xorm:"external_delete_url"` Expires time.Time Created time.Time @@ -29,13 +29,13 @@ type DashboardSnapshot struct { // DashboardSnapshotDTO without dashboard map type DashboardSnapshotDTO struct { - Id int64 `json:"id"` + ID int64 `json:"id" xorm:"id"` Name string `json:"name"` Key string `json:"key"` - OrgId int64 `json:"orgId"` - UserId int64 `json:"userId"` + OrgID int64 `json:"orgId" xorm:"org_id"` + UserID int64 `json:"userId" xorm:"user_id"` External bool `json:"external"` - ExternalUrl string `json:"externalUrl"` + ExternalURL string `json:"externalUrl" xorm:"external_url"` Expires time.Time `json:"expires"` Created time.Time `json:"created"` @@ -63,8 +63,8 @@ type CreateDashboardSnapshotCommand struct { // required:false // default: false External bool `json:"external"` - ExternalUrl string `json:"-"` - ExternalDeleteUrl string `json:"-"` + ExternalURL string `json:"-"` + ExternalDeleteURL string `json:"-"` // Define the unique key. Required if `external` is `true`. // required:false @@ -73,12 +73,10 @@ type CreateDashboardSnapshotCommand struct { // required:false DeleteKey string `json:"deleteKey"` - OrgId int64 `json:"-"` - UserId int64 `json:"-"` + OrgID int64 `json:"-"` + UserID int64 `json:"-"` DashboardEncrypted []byte `json:"-"` - - Result *DashboardSnapshot } type DeleteDashboardSnapshotCommand struct { @@ -92,8 +90,6 @@ type DeleteExpiredSnapshotsCommand struct { type GetDashboardSnapshotQuery struct { Key string DeleteKey string - - Result *DashboardSnapshot } type DashboardSnapshotsList []*DashboardSnapshotDTO @@ -101,8 +97,6 @@ type DashboardSnapshotsList []*DashboardSnapshotDTO type GetDashboardSnapshotsQuery struct { Name string Limit int - OrgId int64 + OrgID int64 SignedInUser *user.SignedInUser - - Result DashboardSnapshotsList } diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index dd63c6476d9..ae229d27fed 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -6,9 +6,9 @@ import ( //go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go type Service interface { - CreateDashboardSnapshot(context.Context, *CreateDashboardSnapshotCommand) error + CreateDashboardSnapshot(context.Context, *CreateDashboardSnapshotCommand) (*DashboardSnapshot, error) DeleteDashboardSnapshot(context.Context, *DeleteDashboardSnapshotCommand) error DeleteExpiredSnapshots(context.Context, *DeleteExpiredSnapshotsCommand) error - GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) error - SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) error + GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error) + SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error) } diff --git a/pkg/services/dashboardsnapshots/service/service.go b/pkg/services/dashboardsnapshots/service/service.go index 4a8b26cae87..247f0e7aec9 100644 --- a/pkg/services/dashboardsnapshots/service/service.go +++ b/pkg/services/dashboardsnapshots/service/service.go @@ -25,15 +25,15 @@ func ProvideService(store dashboardsnapshots.Store, secretsService secrets.Servi return s } -func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) error { +func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (*dashboardsnapshots.DashboardSnapshot, error) { marshalledData, err := cmd.Dashboard.Encode() if err != nil { - return err + return nil, err } encryptedDashboard, err := s.secretsService.Encrypt(ctx, marshalledData, secrets.WithoutScope()) if err != nil { - return err + return nil, err } cmd.DashboardEncrypted = encryptedDashboard @@ -41,34 +41,34 @@ func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboar return s.store.CreateDashboardSnapshot(ctx, cmd) } -func (s *ServiceImpl) GetDashboardSnapshot(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotQuery) error { - err := s.store.GetDashboardSnapshot(ctx, query) +func (s *ServiceImpl) GetDashboardSnapshot(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotQuery) (*dashboardsnapshots.DashboardSnapshot, error) { + queryResult, err := s.store.GetDashboardSnapshot(ctx, query) if err != nil { - return err + return nil, err } - if query.Result.DashboardEncrypted != nil { - decryptedDashboard, err := s.secretsService.Decrypt(ctx, query.Result.DashboardEncrypted) + if queryResult.DashboardEncrypted != nil { + decryptedDashboard, err := s.secretsService.Decrypt(ctx, queryResult.DashboardEncrypted) if err != nil { - return err + return nil, err } dashboard, err := simplejson.NewJson(decryptedDashboard) if err != nil { - return err + return nil, err } - query.Result.Dashboard = dashboard + queryResult.Dashboard = dashboard } - return err + return queryResult, err } func (s *ServiceImpl) DeleteDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.DeleteDashboardSnapshotCommand) error { return s.store.DeleteDashboardSnapshot(ctx, cmd) } -func (s *ServiceImpl) SearchDashboardSnapshots(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotsQuery) error { +func (s *ServiceImpl) SearchDashboardSnapshots(ctx context.Context, query *dashboardsnapshots.GetDashboardSnapshotsQuery) (dashboardsnapshots.DashboardSnapshotsList, error) { return s.store.SearchDashboardSnapshots(ctx, query) } diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 88a72ee6273..d18e77cea67 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -17,7 +17,7 @@ import ( func TestDashboardSnapshotsService(t *testing.T) { sqlStore := db.InitTestDB(t) - dsStore := dashsnapdb.ProvideStore(sqlStore) + dsStore := dashsnapdb.ProvideStore(sqlStore, setting.NewCfg()) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) s := ProvideService(dsStore, secretsService) @@ -42,10 +42,10 @@ func TestDashboardSnapshotsService(t *testing.T) { Dashboard: dashboard, } - err = s.CreateDashboardSnapshot(ctx, &cmd) + result, err := s.CreateDashboardSnapshot(ctx, &cmd) require.NoError(t, err) - decrypted, err := s.secretsService.Decrypt(ctx, cmd.Result.DashboardEncrypted) + decrypted, err := s.secretsService.Decrypt(ctx, result.DashboardEncrypted) require.NoError(t, err) require.Equal(t, rawDashboard, decrypted) @@ -59,10 +59,10 @@ func TestDashboardSnapshotsService(t *testing.T) { DeleteKey: dashboardKey, } - err := s.GetDashboardSnapshot(ctx, &query) + queryResult, err := s.GetDashboardSnapshot(ctx, &query) require.NoError(t, err) - decrypted, err := query.Result.Dashboard.Encode() + decrypted, err := queryResult.Dashboard.Encode() require.NoError(t, err) require.Equal(t, rawDashboard, decrypted) diff --git a/pkg/services/dashboardsnapshots/service_mock.go b/pkg/services/dashboardsnapshots/service_mock.go index efca62367bf..95ae9708c92 100644 --- a/pkg/services/dashboardsnapshots/service_mock.go +++ b/pkg/services/dashboardsnapshots/service_mock.go @@ -1,10 +1,9 @@ -// Code generated by mockery v2.12.2. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package dashboardsnapshots import ( context "context" - testing "testing" mock "github.com/stretchr/testify/mock" ) @@ -15,17 +14,26 @@ type MockService struct { } // CreateDashboardSnapshot provides a mock function with given fields: _a0, _a1 -func (_m *MockService) CreateDashboardSnapshot(_a0 context.Context, _a1 *CreateDashboardSnapshotCommand) error { +func (_m *MockService) CreateDashboardSnapshot(_a0 context.Context, _a1 *CreateDashboardSnapshotCommand) (*DashboardSnapshot, error) { ret := _m.Called(_a0, _a1) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *CreateDashboardSnapshotCommand) error); ok { + var r0 *DashboardSnapshot + if rf, ok := ret.Get(0).(func(context.Context, *CreateDashboardSnapshotCommand) *DashboardSnapshot); ok { r0 = rf(_a0, _a1) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*DashboardSnapshot) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *CreateDashboardSnapshotCommand) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // DeleteDashboardSnapshot provides a mock function with given fields: _a0, _a1 @@ -57,35 +65,58 @@ func (_m *MockService) DeleteExpiredSnapshots(_a0 context.Context, _a1 *DeleteEx } // GetDashboardSnapshot provides a mock function with given fields: _a0, _a1 -func (_m *MockService) GetDashboardSnapshot(_a0 context.Context, _a1 *GetDashboardSnapshotQuery) error { +func (_m *MockService) GetDashboardSnapshot(_a0 context.Context, _a1 *GetDashboardSnapshotQuery) (*DashboardSnapshot, error) { ret := _m.Called(_a0, _a1) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotQuery) error); ok { + var r0 *DashboardSnapshot + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotQuery) *DashboardSnapshot); ok { r0 = rf(_a0, _a1) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*DashboardSnapshot) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // SearchDashboardSnapshots provides a mock function with given fields: _a0, _a1 -func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDashboardSnapshotsQuery) error { +func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error) { ret := _m.Called(_a0, _a1) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotsQuery) error); ok { + var r0 DashboardSnapshotsList + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotsQuery) DashboardSnapshotsList); ok { r0 = rf(_a0, _a1) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(DashboardSnapshotsList) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotsQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } -// NewMockService creates a new instance of MockService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockService(t testing.TB) *MockService { +type mockConstructorTestingTNewMockService interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockService(t mockConstructorTestingTNewMockService) *MockService { mock := &MockService{} mock.Mock.Test(t) diff --git a/pkg/services/dashboardsnapshots/store.go b/pkg/services/dashboardsnapshots/store.go index 392c21f795d..b11c0189aef 100644 --- a/pkg/services/dashboardsnapshots/store.go +++ b/pkg/services/dashboardsnapshots/store.go @@ -5,9 +5,9 @@ import ( ) type Store interface { - CreateDashboardSnapshot(context.Context, *CreateDashboardSnapshotCommand) error + CreateDashboardSnapshot(context.Context, *CreateDashboardSnapshotCommand) (*DashboardSnapshot, error) DeleteDashboardSnapshot(context.Context, *DeleteDashboardSnapshotCommand) error DeleteExpiredSnapshots(context.Context, *DeleteExpiredSnapshotsCommand) error - GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) error - SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) error + GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error) + SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error) } diff --git a/pkg/services/datasourceproxy/datasourceproxy.go b/pkg/services/datasourceproxy/datasourceproxy.go index c6346fa9682..de382029364 100644 --- a/pkg/services/datasourceproxy/datasourceproxy.go +++ b/pkg/services/datasourceproxy/datasourceproxy.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets" @@ -52,7 +52,7 @@ type DataSourceProxyService struct { secretsService secrets.Service } -func (p *DataSourceProxyService) ProxyDataSourceRequest(c *models.ReqContext) { +func (p *DataSourceProxyService) ProxyDataSourceRequest(c *contextmodel.ReqContext) { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { c.JsonApiErr(http.StatusBadRequest, "id is invalid", err) @@ -61,7 +61,7 @@ func (p *DataSourceProxyService) ProxyDataSourceRequest(c *models.ReqContext) { p.ProxyDatasourceRequestWithID(c, id) } -func (p *DataSourceProxyService) ProxyDatasourceRequestWithUID(c *models.ReqContext, dsUID string) { +func (p *DataSourceProxyService) ProxyDatasourceRequestWithUID(c *contextmodel.ReqContext, dsUID string) { c.TimeRequest(metrics.MDataSourceProxyReqTimer) if dsUID == "" { // if datasource UID is not provided, fetch it from the uid path parameter @@ -81,7 +81,7 @@ func (p *DataSourceProxyService) ProxyDatasourceRequestWithUID(c *models.ReqCont p.proxyDatasourceRequest(c, ds) } -func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqContext, dsID int64) { +func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *contextmodel.ReqContext, dsID int64) { c.TimeRequest(metrics.MDataSourceProxyReqTimer) ds, err := p.DataSourceCache.GetDatasource(c.Req.Context(), dsID, c.SignedInUser, c.SkipCache) @@ -92,7 +92,7 @@ func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqConte p.proxyDatasourceRequest(c, ds) } -func toAPIError(c *models.ReqContext, err error) { +func toAPIError(c *contextmodel.ReqContext, err error) { if errors.Is(err, datasources.ErrDataSourceAccessDenied) { c.JsonApiErr(http.StatusForbidden, "Access denied to datasource", err) return @@ -104,7 +104,7 @@ func toAPIError(c *models.ReqContext, err error) { c.JsonApiErr(http.StatusInternalServerError, "Unable to load datasource meta data", err) } -func (p *DataSourceProxyService) proxyDatasourceRequest(c *models.ReqContext, ds *datasources.DataSource) { +func (p *DataSourceProxyService) proxyDatasourceRequest(c *contextmodel.ReqContext, ds *datasources.DataSource) { err := p.PluginRequestValidator.Validate(ds.Url, c.Req) if err != nil { c.JsonApiErr(http.StatusForbidden, "Access denied", err) @@ -138,6 +138,6 @@ func extractProxyPath(originalRawPath string) string { return proxyPathRegexp.ReplaceAllString(originalRawPath, "") } -func getProxyPath(c *models.ReqContext) string { +func getProxyPath(c *contextmodel.ReqContext) string { return extractProxyPath(c.Req.URL.EscapedPath()) } diff --git a/pkg/services/export/entity_store.go b/pkg/services/export/entity_store.go index c6f3af1a4d3..9e956f4adff 100644 --- a/pkg/services/export/entity_store.go +++ b/pkg/services/export/entity_store.go @@ -10,7 +10,6 @@ import ( "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" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" @@ -105,7 +104,7 @@ func (e *entityStoreJob) start(ctx context.Context) { } ctx = appcontext.WithUser(ctx, rowUser) - what := models.StandardKindFolder + what := entity.StandardKindFolder e.status.Count[what] = 0 folders := make(map[int64]string) @@ -133,7 +132,7 @@ func (e *entityStoreJob) start(ctx context.Context) { _, err = e.store.AdminWrite(ctx, &entity.AdminWriteEntityRequest{ GRN: &entity.GRN{ UID: dash.UID, - Kind: models.StandardKindFolder, + Kind: entity.StandardKindFolder, }, ClearHistory: true, CreatedAt: dash.Created.UnixMilli(), @@ -158,7 +157,7 @@ func (e *entityStoreJob) start(ctx context.Context) { e.broadcaster(e.status) } - what = models.StandardKindDashboard + what = entity.StandardKindDashboard e.status.Count[what] = 0 // TODO paging etc @@ -181,7 +180,7 @@ func (e *entityStoreJob) start(ctx context.Context) { _, err = e.store.AdminWrite(ctx, &entity.AdminWriteEntityRequest{ GRN: &entity.GRN{ UID: dash.UID, - Kind: models.StandardKindDashboard, + Kind: entity.StandardKindDashboard, }, ClearHistory: true, Version: fmt.Sprintf("%d", dash.Version), @@ -208,7 +207,7 @@ func (e *entityStoreJob) start(ctx context.Context) { } // Playlists - what = models.StandardKindPlaylist + what = entity.StandardKindPlaylist e.status.Count[what] = 0 rowUser.OrgID = 1 rowUser.UserID = 1 @@ -233,7 +232,7 @@ func (e *entityStoreJob) start(ctx context.Context) { _, err = e.store.Write(ctx, &entity.WriteEntityRequest{ GRN: &entity.GRN{ UID: playlist.Uid, - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, }, Body: prettyJSON(playlist), Comment: "export from playlists", @@ -256,34 +255,34 @@ func (e *entityStoreJob) start(ctx context.Context) { rowUser.OrgID = orgId rowUser.UserID = 1 cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: orgId, + OrgID: orgId, Limit: 500000, SignedInUser: rowUser, } - err := e.dashboardsnapshots.SearchDashboardSnapshots(ctx, cmd) + result, err := e.dashboardsnapshots.SearchDashboardSnapshots(ctx, cmd) if err != nil { e.status.Status = "error: " + err.Error() return } - for _, dto := range cmd.Result { + for _, dto := range result { m := snapshot.Model{ Name: dto.Name, - ExternalURL: dto.ExternalUrl, + ExternalURL: dto.ExternalURL, Expires: dto.Expires.UnixMilli(), } - rowUser.OrgID = dto.OrgId - rowUser.UserID = dto.UserId + rowUser.OrgID = dto.OrgID + rowUser.UserID = dto.UserID snapcmd := &dashboardsnapshots.GetDashboardSnapshotQuery{ Key: dto.Key, } - err = e.dashboardsnapshots.GetDashboardSnapshot(ctx, snapcmd) + snapcmdResult, err := e.dashboardsnapshots.GetDashboardSnapshot(ctx, snapcmd) if err == nil { - res := snapcmd.Result + res := snapcmdResult m.DeleteKey = res.DeleteKey - m.ExternalURL = res.ExternalUrl + m.ExternalURL = res.ExternalURL snap := res.Dashboard m.DashboardUID = snap.Get("uid").MustString("") @@ -297,7 +296,7 @@ func (e *entityStoreJob) start(ctx context.Context) { _, err = e.store.Write(ctx, &entity.WriteEntityRequest{ GRN: &entity.GRN{ UID: dto.Key, - Kind: models.StandardKindSnapshot, + Kind: entity.StandardKindSnapshot, }, Body: prettyJSON(m), Comment: "export from snapshtts", diff --git a/pkg/services/export/export_snapshots.go b/pkg/services/export/export_snapshots.go index 24195a23693..715e93d087d 100644 --- a/pkg/services/export/export_snapshots.go +++ b/pkg/services/export/export_snapshots.go @@ -10,7 +10,7 @@ import ( func exportSnapshots(helper *commitHelper, job *gitExportJob) error { cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ - OrgId: helper.orgID, + OrgID: helper.orgID, Limit: 500000, SignedInUser: nil, } @@ -18,12 +18,12 @@ func exportSnapshots(helper *commitHelper, job *gitExportJob) error { return fmt.Errorf("snapshots requires an admin user") } - err := job.dashboardsnapshotsService.SearchDashboardSnapshots(helper.ctx, cmd) + result, err := job.dashboardsnapshotsService.SearchDashboardSnapshots(helper.ctx, cmd) if err != nil { return err } - if len(cmd.Result) < 1 { + if len(result) < 1 { return nil // nothing } @@ -32,9 +32,9 @@ func exportSnapshots(helper *commitHelper, job *gitExportJob) error { comment: "Export snapshots", } - for _, snapshot := range cmd.Result { + for _, snapshot := range result { gitcmd.body = append(gitcmd.body, commitBody{ - fpath: filepath.Join(helper.orgDir, "snapshot", fmt.Sprintf("%d-snapshot.json", snapshot.Id)), + fpath: filepath.Join(helper.orgDir, "snapshot", fmt.Sprintf("%d-snapshot.json", snapshot.ID)), body: prettyJSON(snapshot), }) } diff --git a/pkg/services/export/export_sys_playlists.go b/pkg/services/export/export_sys_playlists.go index 285f8b85ce9..c9007c8e4d9 100644 --- a/pkg/services/export/export_sys_playlists.go +++ b/pkg/services/export/export_sys_playlists.go @@ -5,8 +5,8 @@ import ( "path/filepath" "time" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/services/store/entity" ) func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { @@ -41,7 +41,7 @@ func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { fpath: filepath.Join( helper.orgDir, "entity", - models.StandardKindPlaylist, + entity.StandardKindPlaylist, fmt.Sprintf("%s.json", playlist.Uid)), body: prettyJSON(playlist), }) diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index 3e7f03e14d1..173635c524b 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -14,7 +14,7 @@ import ( "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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -27,16 +27,16 @@ import ( type ExportService interface { // List folder contents - HandleGetStatus(c *models.ReqContext) response.Response + HandleGetStatus(c *contextmodel.ReqContext) response.Response // List Get Options - HandleGetOptions(c *models.ReqContext) response.Response + HandleGetOptions(c *contextmodel.ReqContext) response.Response // Read raw file contents out of the store - HandleRequestExport(c *models.ReqContext) response.Response + HandleRequestExport(c *contextmodel.ReqContext) response.Response // Cancel any running export - HandleRequestStop(c *models.ReqContext) response.Response + HandleRequestStop(c *contextmodel.ReqContext) response.Response } var exporters = []Exporter{ @@ -186,21 +186,21 @@ func ProvideService(db db.DB, features featuremgmt.FeatureToggles, gl *live.Graf } } -func (ex *StandardExport) HandleGetOptions(c *models.ReqContext) response.Response { +func (ex *StandardExport) HandleGetOptions(c *contextmodel.ReqContext) response.Response { info := map[string]interface{}{ "exporters": exporters, } return response.JSON(http.StatusOK, info) } -func (ex *StandardExport) HandleGetStatus(c *models.ReqContext) response.Response { +func (ex *StandardExport) HandleGetStatus(c *contextmodel.ReqContext) response.Response { ex.mutex.Lock() defer ex.mutex.Unlock() return response.JSON(http.StatusOK, ex.exportJob.getStatus()) } -func (ex *StandardExport) HandleRequestStop(c *models.ReqContext) response.Response { +func (ex *StandardExport) HandleRequestStop(c *contextmodel.ReqContext) response.Response { ex.mutex.Lock() defer ex.mutex.Unlock() @@ -209,7 +209,7 @@ func (ex *StandardExport) HandleRequestStop(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, ex.exportJob.getStatus()) } -func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Response { +func (ex *StandardExport) HandleRequestExport(c *contextmodel.ReqContext) response.Response { var cfg ExportConfig err := json.NewDecoder(c.Req.Body).Decode(&cfg) if err != nil { diff --git a/pkg/services/export/stub.go b/pkg/services/export/stub.go index a0ad9a106f8..fe8d9768d6b 100644 --- a/pkg/services/export/stub.go +++ b/pkg/services/export/stub.go @@ -4,25 +4,25 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) var _ ExportService = new(StubExport) type StubExport struct{} -func (ex *StubExport) HandleGetStatus(c *models.ReqContext) response.Response { +func (ex *StubExport) HandleGetStatus(c *contextmodel.ReqContext) response.Response { return response.Error(http.StatusForbidden, "feature not enabled", nil) } -func (ex *StubExport) HandleGetOptions(c *models.ReqContext) response.Response { +func (ex *StubExport) HandleGetOptions(c *contextmodel.ReqContext) response.Response { return response.Error(http.StatusForbidden, "feature not enabled", nil) } -func (ex *StubExport) HandleRequestExport(c *models.ReqContext) response.Response { +func (ex *StubExport) HandleRequestExport(c *contextmodel.ReqContext) response.Response { return response.Error(http.StatusForbidden, "feature not enabled", nil) } -func (ex *StubExport) HandleRequestStop(c *models.ReqContext) response.Response { +func (ex *StubExport) HandleRequestStop(c *contextmodel.ReqContext) response.Response { return response.Error(http.StatusForbidden, "feature not enabled", nil) } diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go index 19a9f91c6e3..223393fde8f 100644 --- a/pkg/services/featuremgmt/manager.go +++ b/pkg/services/featuremgmt/manager.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/licensing" ) @@ -151,7 +151,7 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag { return v } -func (fm *FeatureManager) HandleGetSettings(c *models.ReqContext) { +func (fm *FeatureManager) HandleGetSettings(c *contextmodel.ReqContext) { res := make(map[string]interface{}, 3) res["enabled"] = fm.GetEnabled(c.Req.Context()) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 77d111cedf0..6db9cb8d4bc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -154,6 +154,12 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "k8sDashboards", + Description: "Save dashboards via k8s", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, { Name: "supportBundles", Description: "Support bundles for troubleshooting", @@ -420,5 +426,19 @@ var ( State: FeatureStateBeta, FrontendOnly: true, }, + { + + Name: "logsSampleInExplore", + Description: "Enables access to the logs sample feature in Explore", + State: FeatureStateStable, + Expression: "true", //turned on by default + FrontendOnly: true, + }, + { + Name: "logsContextDatasourceUi", + Description: "Allow datasource to provide custom UI for context view", + State: FeatureStateAlpha, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3ff5c49df92..617186f858d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -115,6 +115,10 @@ const ( // Explore native k8s integrations FlagK8s = "k8s" + // FlagK8sDashboards + // Save dashboards via k8s + FlagK8sDashboards = "k8sDashboards" + // FlagSupportBundles // Support bundles for troubleshooting FlagSupportBundles = "supportBundles" @@ -306,4 +310,12 @@ const ( // FlagTopNavCommandPalette // Launch the Command Palette from the top navigation search box FlagTopNavCommandPalette = "topNavCommandPalette" + + // FlagLogsSampleInExplore + // Enables access to the logs sample feature in Explore + FlagLogsSampleInExplore = "logsSampleInExplore" + + // FlagLogsContextDatasourceUi + // Allow datasource to provide custom UI for context view + FlagLogsContextDatasourceUi = "logsContextDatasourceUi" ) diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 7951cd3be6c..147cdda2af0 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -25,7 +25,8 @@ func TestFeatureToggleFiles(t *testing.T) { "live-config": true, "live-pipeline": true, "live-service-web-worker": true, - "k8s": true, // Camle case does not like this one + "k8s": true, // Camel case does not like this one + "k8sDashboards": true, // or this one } t.Run("check registry constraints", func(t *testing.T) { diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index f84e8f7565e..f7dfb2e3266 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -51,8 +50,6 @@ func ProvideService( features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, ) folder.Service { - ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(dashboardStore, folderStore)) - ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(dashboardStore, folderStore)) store := ProvideStore(db, cfg, features) svr := &Service{ cfg: cfg, @@ -68,6 +65,9 @@ func ProvideService( if features.IsEnabled(featuremgmt.FlagNestedFolders) { svr.DBMigration(db) } + + ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(dashboardStore, folderStore, svr)) + ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(dashboardStore, folderStore, svr)) return svr } @@ -185,6 +185,13 @@ func (s *Service) GetChildren(ctx context.Context, cmd *folder.GetChildrenQuery) return filtered, nil } +func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + if !s.features.IsEnabled(featuremgmt.FlagNestedFolders) { + return nil, nil + } + return s.store.GetParents(ctx, q) +} + func (s *Service) getFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*folder.Folder, error) { if id == 0 { return &folder.Folder{ID: id, Title: "General"}, nil @@ -310,13 +317,13 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( var permissions []accesscontrol.SetResourcePermissionCommand if user.IsRealUser() && !user.IsAnonymous { permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{ - UserID: userID, Permission: models.PERMISSION_ADMIN.String(), + UserID: userID, Permission: dashboards.PERMISSION_ADMIN.String(), }) } permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{ - {BuiltinRole: string(org.RoleEditor), Permission: models.PERMISSION_EDIT.String()}, - {BuiltinRole: string(org.RoleViewer), Permission: models.PERMISSION_VIEW.String()}, + {BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()}, + {BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()}, }...) _, permissionErr = s.permissions.SetPermissions(ctx, cmd.OrgID, createdFolder.UID, permissions...) @@ -408,12 +415,12 @@ func (s *Service) legacyUpdate(ctx context.Context, cmd *folder.UpdateFolderComm logger := s.log.FromContext(ctx) query := dashboards.GetDashboardQuery{OrgID: cmd.OrgID, UID: cmd.UID} - _, err := s.dashboardStore.GetDashboard(ctx, &query) + queryResult, err := s.dashboardStore.GetDashboard(ctx, &query) if err != nil { return nil, toFolderError(err) } - dashFolder := query.Result + dashFolder := queryResult currentTitle := dashFolder.Title if !dashFolder.IsFolder { @@ -626,7 +633,7 @@ func (s *Service) MakeUserAdmin(ctx context.Context, orgID int64, userID, folder OrgID: orgID, DashboardID: folderID, UserID: userID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: time.Now(), Updated: time.Now(), }, @@ -638,7 +645,7 @@ func (s *Service) MakeUserAdmin(ctx context.Context, orgID int64, userID, folder OrgID: orgID, DashboardID: folderID, Role: &rtEditor, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, Created: time.Now(), Updated: time.Now(), }, @@ -646,7 +653,7 @@ func (s *Service) MakeUserAdmin(ctx context.Context, orgID int64, userID, folder OrgID: orgID, DashboardID: folderID, Role: &rtViewer, - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, Created: time.Now(), Updated: time.Now(), }, diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 4c27d2c5f87..948178d3ace 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" @@ -127,11 +126,9 @@ func TestIntegrationFolderService(t *testing.T) { title := "Folder-TEST" t.Run("When updating folder should return access denied error", func(t *testing.T) { - dashStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - folder := args.Get(1).(*dashboards.GetDashboardQuery) - folder.Result = dashboards.NewDashboard("dashboard-test") - folder.Result.IsFolder = true - }).Return(&dashboards.Dashboard{}, nil) + folderResult := dashboards.NewDashboard("dashboard-test") + folderResult.IsFolder = true + dashStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(folderResult, nil) _, err := service.Update(context.Background(), &folder.UpdateFolderCommand{ UID: folderUID, OrgID: orgID, @@ -142,8 +139,8 @@ func TestIntegrationFolderService(t *testing.T) { }) t.Run("When deleting folder by uid should return access denied error", func(t *testing.T) { - newFolder := models.NewFolder("Folder") - newFolder.Uid = folderUID + newFolder := folder.NewFolder("Folder", "") + newFolder.UID = folderUID folderStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(newFolder, nil) folderStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(newFolder, nil) diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index e3ca1f36bbb..40797519ee9 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -180,7 +180,7 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F } return nil }) - foldr.Url = dashboards.GetFolderURL(foldr.UID, slugify.Slugify(foldr.Title)) + foldr.URL = dashboards.GetFolderURL(foldr.UID, slugify.Slugify(foldr.Title)) return foldr, err } diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index e5606d87a09..c83be22bec0 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -21,6 +21,11 @@ var _ folder.Service = (*FakeService)(nil) func (s *FakeService) GetChildren(ctx context.Context, cmd *folder.GetChildrenQuery) ([]*folder.Folder, error) { return s.ExpectedFolders, s.ExpectedError } + +func (s *FakeService) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} + func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 75e920ecccc..50394685b9b 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -36,7 +36,7 @@ type Folder struct { // TODO: validate if this field is required/relevant to folders. // currently there is no such column Version int - Url string + URL string UpdatedBy int64 CreatedBy int64 HasACL bool @@ -95,8 +95,8 @@ type UpdateFolderCommand struct { // MoveFolderCommand captures the information required by the folder service // to move a folder. type MoveFolderCommand struct { - UID string `json:"uid"` - NewParentUID string `json:"newParentUid"` + UID string `json:"-"` + NewParentUID string `json:"parentUid"` OrgID int64 `json:"-"` SignedInUser *user.SignedInUser `json:"-"` @@ -146,3 +146,11 @@ type GetChildrenQuery struct { SignedInUser *user.SignedInUser `json:"-"` } + +type HasEditPermissionInFoldersQuery struct { + SignedInUser *user.SignedInUser +} + +type HasAdminPermissionInDashboardsOrFoldersQuery struct { + SignedInUser *user.SignedInUser +} diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 0a06cbb6252..f981ea8a946 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -7,6 +7,9 @@ import ( type Service interface { // GetChildren returns an array containing all child folders. GetChildren(ctx context.Context, cmd *GetChildrenQuery) ([]*Folder, error) + // GetParents returns an array containing add parent folders if nested folders are enabled + // otherwise it returns an empty array + GetParents(ctx context.Context, q GetParentsQuery) ([]*Folder, error) Create(ctx context.Context, cmd *CreateFolderCommand) (*Folder, error) // GetFolder takes a GetFolderCommand and returns a folder matching the diff --git a/pkg/services/guardian/accesscontrol_guardian.go b/pkg/services/guardian/accesscontrol_guardian.go index 1ed6bdc35b9..1a57bba508f 100644 --- a/pkg/services/guardian/accesscontrol_guardian.go +++ b/pkg/services/guardian/accesscontrol_guardian.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -14,10 +13,10 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var permissionMap = map[string]models.PermissionType{ - "View": models.PERMISSION_VIEW, - "Edit": models.PERMISSION_EDIT, - "Admin": models.PERMISSION_ADMIN, +var permissionMap = map[string]dashboards.PermissionType{ + "View": dashboards.PERMISSION_VIEW, + "Edit": dashboards.PERMISSION_EDIT, + "Admin": dashboards.PERMISSION_ADMIN, } var _ DashboardGuardian = new(AccessControlDashboardGuardian) @@ -37,13 +36,14 @@ func NewAccessControlDashboardGuardian( OrgID: user.OrgID, } - if err := dashboardService.GetDashboard(ctx, q); err != nil { + qResult, err := dashboardService.GetDashboard(ctx, q) + if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { return nil, ErrGuardianDashboardNotFound.Errorf("failed to get dashboard by UID: %w", err) } return nil, ErrGuardianGetDashboardFailure.Errorf("failed to get dashboard by UID: %w", err) } - dashboard = q.Result + dashboard = qResult } return &AccessControlDashboardGuardian{ @@ -74,13 +74,14 @@ func NewAccessControlDashboardGuardianByUID( OrgID: user.OrgID, } - if err := dashboardService.GetDashboard(ctx, q); err != nil { + qResult, err := dashboardService.GetDashboard(ctx, q) + if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { return nil, ErrGuardianDashboardNotFound.Errorf("failed to get dashboard by UID: %w", err) } return nil, ErrGuardianGetDashboardFailure.Errorf("failed to get dashboard by UID: %w", err) } - dashboard = q.Result + dashboard = qResult } return &AccessControlDashboardGuardian{ @@ -233,7 +234,7 @@ func (a *AccessControlDashboardGuardian) evaluate(evaluator accesscontrol.Evalua return ok, err } -func (a *AccessControlDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { +func (a *AccessControlDashboardGuardian) CheckPermissionBeforeUpdate(permission dashboards.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { // always true for access control return true, nil } @@ -337,8 +338,9 @@ func (a *AccessControlDashboardGuardian) loadParentFolder(folderID int64) (*dash return &dashboards.Dashboard{UID: accesscontrol.GeneralFolderUID}, nil } folderQuery := &dashboards.GetDashboardQuery{ID: folderID, OrgID: a.user.OrgID} - if err := a.dashboardService.GetDashboard(a.ctx, folderQuery); err != nil { + folderQueryResult, err := a.dashboardService.GetDashboard(a.ctx, folderQuery) + if err != nil { return nil, err } - return folderQuery.Result, nil + return folderQueryResult, nil } diff --git a/pkg/services/guardian/accesscontrol_guardian_test.go b/pkg/services/guardian/accesscontrol_guardian_test.go index 319ce4d26b7..b457977a42a 100644 --- a/pkg/services/guardian/accesscontrol_guardian_test.go +++ b/pkg/services/guardian/accesscontrol_guardian_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -601,7 +602,7 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce }) require.NoError(t, err) ac := accesscontrolmock.New().WithPermissions(permissions) - ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashStore, dashStore)) + ac.RegisterScopeAttributeResolver(dashboards.NewDashboardUIDScopeResolver(dashStore, dashStore, foldertest.NewFakeService())) license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() teamSvc := teamimpl.ProvideService(store, store.Cfg) @@ -616,30 +617,31 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce require.NoError(t, err) if dashboardSvc == nil { fakeDashboardService := dashboards.NewFakeDashboardService(t) + qResult := &dashboards.Dashboard{} fakeDashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResult = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, OrgID: q.OrgID, } - }).Return(nil) + }).Return(qResult, nil) dashboardSvc = fakeDashboardService } g, err := NewAccessControlDashboardGuardian(context.Background(), dash.ID, &user.SignedInUser{OrgID: 1}, store, ac, folderPermissions, dashboardPermissions, dashboardSvc) require.NoError(t, err) + g.dashboard = dash return g, dash } func testDashSvc(t *testing.T) dashboards.DashboardService { dashSvc := dashboards.NewFakeDashboardService(t) + var d *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) d := dashboards.NewDashboard("mocked") d.ID = 1 d.UID = "1" - q.Result = d - }).Return(nil) + }).Return(d, nil) return dashSvc } diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index f33c124f654..f47fe6398e1 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -30,7 +29,7 @@ type DashboardGuardian interface { CanAdmin() (bool, error) CanDelete() (bool, error) CanCreate(folderID int64, isFolder bool) (bool, error) - CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) + CheckPermissionBeforeUpdate(permission dashboards.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) // GetACL returns ACL. GetACL() ([]*dashboards.DashboardACLInfoDTO, error) @@ -81,7 +80,7 @@ func newDashboardGuardian(ctx context.Context, dashId int64, orgId int64, user * OrgID: orgId, } - if err := dashSvc.GetDashboard(ctx, q); err != nil { + if _, err := dashSvc.GetDashboard(ctx, q); err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { return nil, ErrGuardianDashboardNotFound.Errorf("failed to get dashboard by UID: %w", err) } @@ -110,13 +109,14 @@ func newDashboardGuardianByUID(ctx context.Context, dashUID string, orgId int64, OrgID: orgId, } - if err := dashSvc.GetDashboard(ctx, q); err != nil { + qResult, err := dashSvc.GetDashboard(ctx, q) + if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { return nil, ErrGuardianDashboardNotFound.Errorf("failed to get dashboard by UID: %w", err) } return nil, ErrGuardianGetDashboardFailure.Errorf("failed to get dashboard by UID: %w", err) } - dashID = q.Result.ID + dashID = qResult.ID } return &dashboardGuardianImpl{ @@ -148,23 +148,23 @@ func newDashboardGuardianByDashboard(ctx context.Context, dash *dashboards.Dashb } func (g *dashboardGuardianImpl) CanSave() (bool, error) { - return g.HasPermission(models.PERMISSION_EDIT) + return g.HasPermission(dashboards.PERMISSION_EDIT) } func (g *dashboardGuardianImpl) CanEdit() (bool, error) { if setting.ViewersCanEdit { - return g.HasPermission(models.PERMISSION_VIEW) + return g.HasPermission(dashboards.PERMISSION_VIEW) } - return g.HasPermission(models.PERMISSION_EDIT) + return g.HasPermission(dashboards.PERMISSION_EDIT) } func (g *dashboardGuardianImpl) CanView() (bool, error) { - return g.HasPermission(models.PERMISSION_VIEW) + return g.HasPermission(dashboards.PERMISSION_VIEW) } func (g *dashboardGuardianImpl) CanAdmin() (bool, error) { - return g.HasPermission(models.PERMISSION_ADMIN) + return g.HasPermission(dashboards.PERMISSION_ADMIN) } func (g *dashboardGuardianImpl) CanDelete() (bool, error) { @@ -177,7 +177,7 @@ func (g *dashboardGuardianImpl) CanCreate(_ int64, _ bool) (bool, error) { return g.CanSave() } -func (g *dashboardGuardianImpl) HasPermission(permission models.PermissionType) (bool, error) { +func (g *dashboardGuardianImpl) HasPermission(permission dashboards.PermissionType) (bool, error) { if g.user.OrgRole == org.RoleAdmin { return g.logHasPermissionResult(permission, true, nil) } @@ -191,7 +191,7 @@ func (g *dashboardGuardianImpl) HasPermission(permission models.PermissionType) return g.logHasPermissionResult(permission, result, err) } -func (g *dashboardGuardianImpl) logHasPermissionResult(permission models.PermissionType, hasPermission bool, err error) (bool, error) { +func (g *dashboardGuardianImpl) logHasPermissionResult(permission dashboards.PermissionType, hasPermission bool, err error) (bool, error) { if err != nil { return hasPermission, err } @@ -205,7 +205,7 @@ func (g *dashboardGuardianImpl) logHasPermissionResult(permission models.Permiss return hasPermission, err } -func (g *dashboardGuardianImpl) checkACL(permission models.PermissionType, acl []*dashboards.DashboardACLInfoDTO) (bool, error) { +func (g *dashboardGuardianImpl) checkACL(permission dashboards.PermissionType, acl []*dashboards.DashboardACLInfoDTO) (bool, error) { orgRole := g.user.OrgRole teamACLItems := []*dashboards.DashboardACLInfoDTO{} @@ -253,10 +253,10 @@ func (g *dashboardGuardianImpl) checkACL(permission models.PermissionType, acl [ return false, nil } -func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { +func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission dashboards.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { acl := []*dashboards.DashboardACLInfoDTO{} adminRole := org.RoleAdmin - everyoneWithAdminRole := &dashboards.DashboardACLInfoDTO{DashboardID: g.dashId, UserID: 0, TeamID: 0, Role: &adminRole, Permission: models.PERMISSION_ADMIN} + everyoneWithAdminRole := &dashboards.DashboardACLInfoDTO{DashboardID: g.dashId, UserID: 0, TeamID: 0, Role: &adminRole, Permission: dashboards.PERMISSION_ADMIN} // validate that duplicate permissions don't exists for _, p := range updatePermissions { @@ -306,10 +306,11 @@ func (g *dashboardGuardianImpl) GetACL() ([]*dashboards.DashboardACLInfoDTO, err } query := dashboards.GetDashboardACLInfoListQuery{DashboardID: g.dashId, OrgID: g.orgId} - if err := g.dashboardService.GetDashboardACLInfoList(g.ctx, &query); err != nil { + queryResult, err := g.dashboardService.GetDashboardACLInfoList(g.ctx, &query) + if err != nil { return nil, err } - g.acl = query.Result + g.acl = queryResult return g.acl, nil } @@ -434,11 +435,11 @@ func (g *FakeDashboardGuardian) CanCreate(_ int64, _ bool) (bool, error) { return g.CanSaveValue, nil } -func (g *FakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) { +func (g *FakeDashboardGuardian) HasPermission(permission dashboards.PermissionType) (bool, error) { return g.HasPermissionValue, nil } -func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { +func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission dashboards.PermissionType, updatePermissions []*dashboards.DashboardACL) (bool, error) { return g.CheckPermissionBeforeUpdateValue, g.CheckPermissionBeforeUpdateError } diff --git a/pkg/services/guardian/guardian_test.go b/pkg/services/guardian/guardian_test.go index d10611094c1..d7d3a8b5a9d 100644 --- a/pkg/services/guardian/guardian_test.go +++ b/pkg/services/guardian/guardian_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team/teamtest" @@ -44,44 +43,44 @@ func TestGuardianAdmin(t *testing.T) { sc.defaultPermissionScenario(USER, FULL_ACCESS) // dashboard has user with permission - sc.dashboardPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_EDIT, FULL_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_VIEW, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_VIEW, FULL_ACCESS) // dashboard has team with permission - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_EDIT, FULL_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_VIEW, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, FULL_ACCESS) // dashboard has editor role with permission - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_EDIT, FULL_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_VIEW, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, FULL_ACCESS) // dashboard has viewer role with permission - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_EDIT, FULL_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_VIEW, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, FULL_ACCESS) // parent folder has user with permission - sc.parentFolderPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_EDIT, FULL_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_VIEW, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_VIEW, FULL_ACCESS) // parent folder has team with permission - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_EDIT, FULL_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_VIEW, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, FULL_ACCESS) // parent folder has editor role with permission - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_EDIT, FULL_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_VIEW, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, FULL_ACCESS) // parent folder has viewer role with permission - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_EDIT, FULL_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_VIEW, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, FULL_ACCESS) }) } @@ -91,44 +90,44 @@ func TestGuardianEditor(t *testing.T) { sc.defaultPermissionScenario(USER, EDITOR_ACCESS) // dashboard has user with permission - sc.dashboardPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_VIEW, CAN_VIEW) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_VIEW, CAN_VIEW) // dashboard has team with permission - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_VIEW, CAN_VIEW) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, CAN_VIEW) // dashboard has editor role with permission - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // dashboard has viewer role with permission - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_ADMIN, NO_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_EDIT, NO_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_VIEW, NO_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, NO_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, NO_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, NO_ACCESS) // parent folder has user with permission - sc.parentFolderPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has team with permission - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has editor role with permission - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has viewer role with permission - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_ADMIN, NO_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_EDIT, NO_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_VIEW, NO_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, NO_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, NO_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, NO_ACCESS) }) } @@ -138,44 +137,44 @@ func TestGuardianViewer(t *testing.T) { sc.defaultPermissionScenario(USER, VIEWER_ACCESS) // dashboard has user with permission - sc.dashboardPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(USER, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(USER, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // dashboard has team with permission - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(TEAM, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // dashboard has editor role with permission - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_ADMIN, NO_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_EDIT, NO_ACCESS) - sc.dashboardPermissionScenario(EDITOR, models.PERMISSION_VIEW, NO_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, NO_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, NO_ACCESS) + sc.dashboardPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, NO_ACCESS) // dashboard has viewer role with permission - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.dashboardPermissionScenario(VIEWER, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.dashboardPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has user with permission - sc.parentFolderPermissionScenario(USER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(USER, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(USER, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has team with permission - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(TEAM, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(TEAM, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) // parent folder has editor role with permission - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_ADMIN, NO_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_EDIT, NO_ACCESS) - sc.parentFolderPermissionScenario(EDITOR, models.PERMISSION_VIEW, NO_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_ADMIN, NO_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_EDIT, NO_ACCESS) + sc.parentFolderPermissionScenario(EDITOR, dashboards.PERMISSION_VIEW, NO_ACCESS) // parent folder has viewer role with permission - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_ADMIN, FULL_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_EDIT, EDITOR_ACCESS) - sc.parentFolderPermissionScenario(VIEWER, models.PERMISSION_VIEW, VIEWER_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_ADMIN, FULL_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_EDIT, EDITOR_ACCESS) + sc.parentFolderPermissionScenario(VIEWER, dashboards.PERMISSION_VIEW, VIEWER_ACCESS) }) apiKeyScenario("Given api key with viewer role", t, org.RoleViewer, func(sc *scenarioContext) { @@ -189,8 +188,8 @@ func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, flag per sc.callerFile = callerFile sc.callerLine = callerLine existingPermissions := []*dashboards.DashboardACLInfoDTO{ - toDto(newEditorRolePermission(defaultDashboardID, models.PERMISSION_EDIT)), - toDto(newViewerRolePermission(defaultDashboardID, models.PERMISSION_VIEW)), + toDto(newEditorRolePermission(defaultDashboardID, dashboards.PERMISSION_EDIT)), + toDto(newViewerRolePermission(defaultDashboardID, dashboards.PERMISSION_VIEW)), } permissionScenario("and existing permissions are the default permissions (everyone with editor role can edit, everyone with viewer role can view)", @@ -203,7 +202,7 @@ func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, flag per }) } -func (sc *scenarioContext) dashboardPermissionScenario(pt permissionType, permission models.PermissionType, flag permissionFlags) { +func (sc *scenarioContext) dashboardPermissionScenario(pt permissionType, permission dashboards.PermissionType, flag permissionFlags) { _, callerFile, callerLine, _ := runtime.Caller(1) sc.callerFile = callerFile sc.callerLine = callerLine @@ -230,7 +229,7 @@ func (sc *scenarioContext) dashboardPermissionScenario(pt permissionType, permis }) } -func (sc *scenarioContext) parentFolderPermissionScenario(pt permissionType, permission models.PermissionType, flag permissionFlags) { +func (sc *scenarioContext) parentFolderPermissionScenario(pt permissionType, permission dashboards.PermissionType, flag permissionFlags) { _, callerFile, callerLine, _ := runtime.Caller(1) sc.callerFile = callerFile sc.callerLine = callerLine @@ -313,11 +312,11 @@ func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { tc := "When updating dashboard permissions with duplicate permission for user should not be allowed" sc.t.Run(tc, func(t *testing.T) { p := []*dashboards.DashboardACL{ - newDefaultUserPermission(dashboardID, models.PERMISSION_VIEW), - newDefaultUserPermission(dashboardID, models.PERMISSION_ADMIN), + newDefaultUserPermission(dashboardID, dashboards.PERMISSION_VIEW), + newDefaultUserPermission(dashboardID, dashboards.PERMISSION_ADMIN), } sc.updatePermissions = p - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, p) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, p) if !errors.Is(err, ErrGuardianPermissionExists) { sc.reportFailure(tc, ErrGuardianPermissionExists, err) @@ -328,11 +327,11 @@ func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { tc = "When updating dashboard permissions with duplicate permission for team should not be allowed" sc.t.Run(tc, func(t *testing.T) { p := []*dashboards.DashboardACL{ - newDefaultTeamPermission(dashboardID, models.PERMISSION_VIEW), - newDefaultTeamPermission(dashboardID, models.PERMISSION_ADMIN), + newDefaultTeamPermission(dashboardID, dashboards.PERMISSION_VIEW), + newDefaultTeamPermission(dashboardID, dashboards.PERMISSION_ADMIN), } sc.updatePermissions = p - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, p) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, p) if !errors.Is(err, ErrGuardianPermissionExists) { sc.reportFailure(tc, ErrGuardianPermissionExists, err) } @@ -342,11 +341,11 @@ func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { tc = "When updating dashboard permissions with duplicate permission for editor role should not be allowed" sc.t.Run(tc, func(t *testing.T) { p := []*dashboards.DashboardACL{ - newEditorRolePermission(dashboardID, models.PERMISSION_VIEW), - newEditorRolePermission(dashboardID, models.PERMISSION_ADMIN), + newEditorRolePermission(dashboardID, dashboards.PERMISSION_VIEW), + newEditorRolePermission(dashboardID, dashboards.PERMISSION_ADMIN), } sc.updatePermissions = p - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, p) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, p) if !errors.Is(err, ErrGuardianPermissionExists) { sc.reportFailure(tc, ErrGuardianPermissionExists, err) @@ -357,11 +356,11 @@ func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { tc = "When updating dashboard permissions with duplicate permission for viewer role should not be allowed" sc.t.Run(tc, func(t *testing.T) { p := []*dashboards.DashboardACL{ - newViewerRolePermission(dashboardID, models.PERMISSION_VIEW), - newViewerRolePermission(dashboardID, models.PERMISSION_ADMIN), + newViewerRolePermission(dashboardID, dashboards.PERMISSION_VIEW), + newViewerRolePermission(dashboardID, dashboards.PERMISSION_ADMIN), } sc.updatePermissions = p - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, p) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, p) if !errors.Is(err, ErrGuardianPermissionExists) { sc.reportFailure(tc, ErrGuardianPermissionExists, err) } @@ -371,10 +370,10 @@ func (sc *scenarioContext) verifyDuplicatePermissionsShouldNotBeAllowed() { tc = "When updating dashboard permissions with duplicate permission for admin role should not be allowed" sc.t.Run(tc, func(t *testing.T) { p := []*dashboards.DashboardACL{ - newAdminRolePermission(dashboardID, models.PERMISSION_ADMIN), + newAdminRolePermission(dashboardID, dashboards.PERMISSION_ADMIN), } sc.updatePermissions = p - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, p) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, p) if !errors.Is(err, ErrGuardianPermissionExists) { sc.reportFailure(tc, ErrGuardianPermissionExists, err) } @@ -387,7 +386,7 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldBeAllowed(pt pe return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { tc := fmt.Sprintf("When updating dashboard permissions with %s permissions should be allowed", p.String()) sc.t.Run(tc, func(t *testing.T) { permissionList := []*dashboards.DashboardACL{} @@ -416,7 +415,7 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldBeAllowed(pt pe } sc.updatePermissions = permissionList - ok, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + ok, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } @@ -433,7 +432,7 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { tc := fmt.Sprintf("When updating dashboard permissions with %s permissions should NOT be allowed", p.String()) sc.t.Run(tc, func(t *testing.T) { permissionList := []*dashboards.DashboardACL{ @@ -456,7 +455,7 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt } sc.updatePermissions = permissionList - ok, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + ok, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } @@ -468,12 +467,12 @@ func (sc *scenarioContext) verifyUpdateDashboardPermissionsShouldNotBeAllowed(pt } } -func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldBeAllowed(pt permissionType, parentFolderPermission models.PermissionType) { +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldBeAllowed(pt permissionType, parentFolderPermission dashboards.PermissionType) { if !sc.expectedFlags.canAdmin() { return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { tc := fmt.Sprintf("When updating child dashboard permissions with %s permissions should be allowed", p.String()) sc.t.Run(tc, func(t *testing.T) { permissionList := []*dashboards.DashboardACL{} @@ -517,7 +516,7 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldBeAllowed( } sc.updatePermissions = permissionList - ok, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + ok, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } @@ -529,12 +528,12 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldBeAllowed( } } -func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldNotBeAllowed(pt permissionType, parentFolderPermission models.PermissionType) { +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldNotBeAllowed(pt permissionType, parentFolderPermission dashboards.PermissionType) { if sc.expectedFlags.canAdmin() { return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { tc := fmt.Sprintf("When updating child dashboard permissions with %s permissions should NOT be allowed", p.String()) sc.t.Run(tc, func(t *testing.T) { permissionList := []*dashboards.DashboardACL{} @@ -578,7 +577,7 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldNotBeAllow } sc.updatePermissions = permissionList - ok, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + ok, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } @@ -590,12 +589,12 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsShouldNotBeAllow } } -func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldBeAllowed(pt permissionType, parentFolderPermission models.PermissionType) { +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldBeAllowed(pt permissionType, parentFolderPermission dashboards.PermissionType) { if !sc.expectedFlags.canAdmin() { return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { // permission to update is higher than parent folder permission if p > parentFolderPermission { continue @@ -624,7 +623,7 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShou } sc.updatePermissions = permissionList - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if !errors.Is(err, ErrGuardianOverride) { sc.reportFailure(tc, ErrGuardianOverride, err) } @@ -633,12 +632,12 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShou } } -func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldNotBeAllowed(pt permissionType, parentFolderPermission models.PermissionType) { +func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShouldNotBeAllowed(pt permissionType, parentFolderPermission dashboards.PermissionType) { if !sc.expectedFlags.canAdmin() { return } - for _, p := range []models.PermissionType{models.PERMISSION_ADMIN, models.PERMISSION_EDIT, models.PERMISSION_VIEW} { + for _, p := range []dashboards.PermissionType{dashboards.PERMISSION_ADMIN, dashboards.PERMISSION_EDIT, dashboards.PERMISSION_VIEW} { // permission to update is lower than or equal to parent folder permission if p <= parentFolderPermission { continue @@ -669,12 +668,12 @@ func (sc *scenarioContext) verifyUpdateChildDashboardPermissionsWithOverrideShou } } - _, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + _, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } sc.updatePermissions = permissionList - ok, err := sc.g.CheckPermissionBeforeUpdate(models.PERMISSION_ADMIN, permissionList) + ok, err := sc.g.CheckPermissionBeforeUpdate(dashboards.PERMISSION_ADMIN, permissionList) if err != nil { sc.reportFailure(tc, nil, err) } @@ -690,22 +689,21 @@ func TestGuardianGetHiddenACL(t *testing.T) { t.Run("Get hidden ACL tests", func(t *testing.T) { store := dbtest.NewFakeDB() dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Inherited: false, UserID: 1, UserLogin: "user1", Permission: models.PERMISSION_EDIT}, - {Inherited: false, UserID: 2, UserLogin: "user2", Permission: models.PERMISSION_ADMIN}, - {Inherited: true, UserID: 3, UserLogin: "user3", Permission: models.PERMISSION_VIEW}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {Inherited: false, UserID: 1, UserLogin: "user1", Permission: dashboards.PERMISSION_EDIT}, + {Inherited: false, UserID: 2, UserLogin: "user2", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: true, UserID: 3, UserLogin: "user3", Permission: dashboards.PERMISSION_VIEW}, + } + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) + var qResultDash *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResultDash = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, OrgID: q.OrgID, } - }).Return(nil) + }).Return(qResultDash, nil) cfg := setting.NewCfg() cfg.HiddenUsers = map[string]struct{}{"user2": {}} @@ -734,13 +732,9 @@ func TestGuardianGetHiddenACL(t *testing.T) { IsGrafanaAdmin: true, } dashSvc := dashboards.NewFakeDashboardService(t) + qResult := &dashboards.Dashboard{} dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ - ID: q.ID, - UID: q.UID, - } - }).Return(nil) + }).Return(qResult, nil) g, err := newDashboardGuardian(context.Background(), dashboardID, orgID, user, store, dashSvc, &teamtest.FakeService{}) require.NoError(t, err) @@ -756,27 +750,26 @@ func TestGuardianGetACLWithoutDuplicates(t *testing.T) { t.Run("Get hidden ACL tests", func(t *testing.T) { store := dbtest.NewFakeDB() dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = []*dashboards.DashboardACLInfoDTO{ - {Inherited: true, UserID: 3, UserLogin: "user3", Permission: models.PERMISSION_EDIT}, - {Inherited: false, UserID: 3, UserLogin: "user3", Permission: models.PERMISSION_VIEW}, - {Inherited: false, UserID: 2, UserLogin: "user2", Permission: models.PERMISSION_ADMIN}, - {Inherited: true, UserID: 4, UserLogin: "user4", Permission: models.PERMISSION_ADMIN}, - {Inherited: false, UserID: 4, UserLogin: "user4", Permission: models.PERMISSION_ADMIN}, - {Inherited: false, UserID: 5, UserLogin: "user5", Permission: models.PERMISSION_EDIT}, - {Inherited: true, UserID: 6, UserLogin: "user6", Permission: models.PERMISSION_VIEW}, - {Inherited: false, UserID: 6, UserLogin: "user6", Permission: models.PERMISSION_EDIT}, - } - }).Return(nil) + qResult := []*dashboards.DashboardACLInfoDTO{ + {Inherited: true, UserID: 3, UserLogin: "user3", Permission: dashboards.PERMISSION_EDIT}, + {Inherited: false, UserID: 3, UserLogin: "user3", Permission: dashboards.PERMISSION_VIEW}, + {Inherited: false, UserID: 2, UserLogin: "user2", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: true, UserID: 4, UserLogin: "user4", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: false, UserID: 4, UserLogin: "user4", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: false, UserID: 5, UserLogin: "user5", Permission: dashboards.PERMISSION_EDIT}, + {Inherited: true, UserID: 6, UserLogin: "user6", Permission: dashboards.PERMISSION_VIEW}, + {Inherited: false, UserID: 6, UserLogin: "user6", Permission: dashboards.PERMISSION_EDIT}, + } + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) + qResultDash := &dashboards.Dashboard{} dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResultDash = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, OrgID: q.OrgID, } - }).Return(nil) + }).Return(qResultDash, nil) t.Run("Should get acl without duplicates", func(t *testing.T) { user := &user.SignedInUser{ @@ -792,12 +785,12 @@ func TestGuardianGetACLWithoutDuplicates(t *testing.T) { require.NotNil(t, acl) require.Len(t, acl, 6) require.ElementsMatch(t, []*dashboards.DashboardACLInfoDTO{ - {Inherited: true, UserID: 3, UserLogin: "user3", Permission: models.PERMISSION_EDIT}, - {Inherited: true, UserID: 4, UserLogin: "user4", Permission: models.PERMISSION_ADMIN}, - {Inherited: true, UserID: 6, UserLogin: "user6", Permission: models.PERMISSION_VIEW}, - {Inherited: false, UserID: 2, UserLogin: "user2", Permission: models.PERMISSION_ADMIN}, - {Inherited: false, UserID: 5, UserLogin: "user5", Permission: models.PERMISSION_EDIT}, - {Inherited: false, UserID: 6, UserLogin: "user6", Permission: models.PERMISSION_EDIT}, + {Inherited: true, UserID: 3, UserLogin: "user3", Permission: dashboards.PERMISSION_EDIT}, + {Inherited: true, UserID: 4, UserLogin: "user4", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: true, UserID: 6, UserLogin: "user6", Permission: dashboards.PERMISSION_VIEW}, + {Inherited: false, UserID: 2, UserLogin: "user2", Permission: dashboards.PERMISSION_ADMIN}, + {Inherited: false, UserID: 5, UserLogin: "user5", Permission: dashboards.PERMISSION_EDIT}, + {Inherited: false, UserID: 6, UserLogin: "user6", Permission: dashboards.PERMISSION_EDIT}, }, acl) }) }) diff --git a/pkg/services/guardian/guardian_util_test.go b/pkg/services/guardian/guardian_util_test.go index 982ce3b9024..ba84307fe48 100644 --- a/pkg/services/guardian/guardian_util_test.go +++ b/pkg/services/guardian/guardian_util_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -47,13 +46,14 @@ func orgRoleScenario(desc string, t *testing.T, role org.RoleType, fn scenarioFu store := dbtest.NewFakeDB() fakeDashboardService := dashboards.NewFakeDashboardService(t) + var qResult *dashboards.Dashboard fakeDashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResult = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, } - }).Return(nil) + }).Return(qResult, nil) guard, err := newDashboardGuardian(context.Background(), dashboardID, orgID, user, store, fakeDashboardService, &teamtest.FakeService{}) require.NoError(t, err) @@ -78,13 +78,14 @@ func apiKeyScenario(desc string, t *testing.T, role org.RoleType, fn scenarioFun } store := dbtest.NewFakeDB() dashSvc := dashboards.NewFakeDashboardService(t) + var qResult *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResult = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, } - }).Return(nil) + }).Return(qResult, nil) guard, err := newDashboardGuardian(context.Background(), dashboardID, orgID, user, store, dashSvc, &teamtest.FakeService{}) require.NoError(t, err) @@ -114,18 +115,17 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, teamSvc := &teamtest.FakeService{ExpectedTeamsByUser: teams} dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardACLInfoListQuery) - q.Result = permissions - }).Return(nil) + qResult := permissions + dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResult, nil) + qResultDash := &dashboards.Dashboard{} dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + qResultDash = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, OrgID: q.OrgID, } - }).Return(nil) + }).Return(qResultDash, nil) sc.permissionScenario = desc g, err := newDashboardGuardian(context.Background(), dashboardID, sc.givenUser.OrgID, sc.givenUser, store, dashSvc, teamSvc) @@ -261,31 +261,31 @@ func (sc *scenarioContext) reportFailure(desc string, expected interface{}, actu sc.t.Fatalf(buf.String()) } -func newCustomUserPermission(dashboardID int64, userID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newCustomUserPermission(dashboardID int64, userID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return &dashboards.DashboardACL{OrgID: orgID, DashboardID: dashboardID, UserID: userID, Permission: permission} } -func newDefaultUserPermission(dashboardID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newDefaultUserPermission(dashboardID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return newCustomUserPermission(dashboardID, userID, permission) } -func newCustomTeamPermission(dashboardID int64, teamID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newCustomTeamPermission(dashboardID int64, teamID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return &dashboards.DashboardACL{OrgID: orgID, DashboardID: dashboardID, TeamID: teamID, Permission: permission} } -func newDefaultTeamPermission(dashboardID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newDefaultTeamPermission(dashboardID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return newCustomTeamPermission(dashboardID, teamID, permission) } -func newAdminRolePermission(dashboardID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newAdminRolePermission(dashboardID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return &dashboards.DashboardACL{OrgID: orgID, DashboardID: dashboardID, Role: &adminRole, Permission: permission} } -func newEditorRolePermission(dashboardID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newEditorRolePermission(dashboardID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return &dashboards.DashboardACL{OrgID: orgID, DashboardID: dashboardID, Role: &editorRole, Permission: permission} } -func newViewerRolePermission(dashboardID int64, permission models.PermissionType) *dashboards.DashboardACL { +func newViewerRolePermission(dashboardID int64, permission dashboards.PermissionType) *dashboards.DashboardACL { return &dashboards.DashboardACL{OrgID: orgID, DashboardID: dashboardID, Role: &viewerRole, Permission: permission} } diff --git a/pkg/services/hooks/hooks.go b/pkg/services/hooks/hooks.go index 87e93f7e721..af36933ee20 100644 --- a/pkg/services/hooks/hooks.go +++ b/pkg/services/hooks/hooks.go @@ -2,12 +2,13 @@ package hooks import ( "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/login" ) -type IndexDataHook func(indexData *dtos.IndexViewData, req *models.ReqContext) +type IndexDataHook func(indexData *dtos.IndexViewData, req *contextmodel.ReqContext) -type LoginHook func(loginInfo *models.LoginInfo, req *models.ReqContext) +type LoginHook func(loginInfo *login.LoginInfo, req *contextmodel.ReqContext) type HooksService struct { indexDataHooks []IndexDataHook @@ -22,7 +23,7 @@ func (srv *HooksService) AddIndexDataHook(hook IndexDataHook) { srv.indexDataHooks = append(srv.indexDataHooks, hook) } -func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData, req *models.ReqContext) { +func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData, req *contextmodel.ReqContext) { for _, hook := range srv.indexDataHooks { hook(indexData, req) } @@ -32,7 +33,7 @@ func (srv *HooksService) AddLoginHook(hook LoginHook) { srv.loginHooks = append(srv.loginHooks, hook) } -func (srv *HooksService) RunLoginHook(loginInfo *models.LoginInfo, req *models.ReqContext) { +func (srv *HooksService) RunLoginHook(loginInfo *login.LoginInfo, req *contextmodel.ReqContext) { for _, hook := range srv.loginHooks { hook(loginInfo, req) } diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 934a3c0c8e0..8a65c939fcd 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -15,7 +15,6 @@ import ( "gopkg.in/ldap.v3" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" ) @@ -33,8 +32,8 @@ type IConnection interface { // IServer is interface for LDAP authorization type IServer interface { - Login(*models.LoginUserQuery) (*models.ExternalUserInfo, error) - Users([]string) ([]*models.ExternalUserInfo, error) + Login(*login.LoginUserQuery) (*login.ExternalUserInfo, error) + Users([]string) ([]*login.ExternalUserInfo, error) Bind() error UserBind(string, string) error Dial() error @@ -202,8 +201,8 @@ func (server *Server) Close() { // // Dial() sets the connection with the server for this Struct. Therefore, we require a // call to Dial() before being able to execute this function. -func (server *Server) Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, error, +func (server *Server) Login(query *login.LoginUserQuery) ( + *login.ExternalUserInfo, error, ) { var err error var authAndBind bool @@ -279,7 +278,7 @@ func (server *Server) shouldSingleBind() bool { // Dial() sets the connection with the server for this Struct. Therefore, we require a // call to Dial() before being able to execute this function. func (server *Server) Users(logins []string) ( - []*models.ExternalUserInfo, + []*login.ExternalUserInfo, error, ) { var users [][]*ldap.Entry @@ -293,7 +292,7 @@ func (server *Server) Users(logins []string) ( } if len(users) == 0 { - return []*models.ExternalUserInfo{}, nil + return []*login.ExternalUserInfo{}, nil } serializedUsers, err := server.serializeUsers(users) @@ -361,7 +360,7 @@ func (server *Server) users(logins []string) ( // validateGrafanaUser validates user access. // If there are no ldap group mappings access is true // otherwise a single group must match -func (server *Server) validateGrafanaUser(user *models.ExternalUserInfo) error { +func (server *Server) validateGrafanaUser(user *login.ExternalUserInfo) error { if !SkipOrgRoleSync() && len(server.Config.Groups) > 0 && (len(user.OrgRoles) == 0 && (user.IsGrafanaAdmin == nil || !*user.IsGrafanaAdmin)) { server.log.Error( @@ -423,14 +422,14 @@ func (server *Server) getSearchRequest( } // buildGrafanaUser extracts info from UserInfo model to ExternalUserInfo -func (server *Server) buildGrafanaUser(user *ldap.Entry) (*models.ExternalUserInfo, error) { +func (server *Server) buildGrafanaUser(user *ldap.Entry) (*login.ExternalUserInfo, error) { memberOf, err := server.getMemberOf(user) if err != nil { return nil, err } attrs := server.Config.Attr - extUser := &models.ExternalUserInfo{ + extUser := &login.ExternalUserInfo{ AuthModule: login.LDAPAuthModule, AuthId: user.DN, Name: strings.TrimSpace( @@ -595,8 +594,8 @@ func (server *Server) requestMemberOf(entry *ldap.Entry) ([]string, error) { // from LDAP result to ExternalInfo struct func (server *Server) serializeUsers( entries [][]*ldap.Entry, -) ([]*models.ExternalUserInfo, error) { - var serialized []*models.ExternalUserInfo +) ([]*login.ExternalUserInfo, error) { + var serialized []*login.ExternalUserInfo var users = map[string]struct{}{} for _, dn := range entries { diff --git a/pkg/services/ldap/ldap_groups.go b/pkg/services/ldap/ldap_groups.go index a3e6053e575..be23712313d 100644 --- a/pkg/services/ldap/ldap_groups.go +++ b/pkg/services/ldap/ldap_groups.go @@ -1,9 +1,7 @@ package ldap -import "github.com/grafana/grafana/pkg/models" - type Groups interface { - GetTeams(groups []string, orgIDs []int64) ([]models.TeamOrgGroupDTO, error) + GetTeams(groups []string, orgIDs []int64) ([]TeamOrgGroupDTO, error) } type OSSGroups struct{} @@ -12,6 +10,6 @@ func ProvideGroupsService() *OSSGroups { return &OSSGroups{} } -func (*OSSGroups) GetTeams(_ []string, _ []int64) ([]models.TeamOrgGroupDTO, error) { +func (*OSSGroups) GetTeams(_ []string, _ []int64) ([]TeamOrgGroupDTO, error) { return nil, nil } diff --git a/pkg/services/ldap/ldap_login_test.go b/pkg/services/ldap/ldap_login_test.go index 7b552a8edfa..c31b2cb4dbd 100644 --- a/pkg/services/ldap/ldap_login_test.go +++ b/pkg/services/ldap/ldap_login_test.go @@ -10,10 +10,10 @@ import ( "gopkg.in/ldap.v3" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" ) -var defaultLogin = &models.LoginUserQuery{ +var defaultLogin = &login.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", diff --git a/pkg/services/ldap/ldap_private_test.go b/pkg/services/ldap/ldap_private_test.go index b7846f58f6c..e9d534051eb 100644 --- a/pkg/services/ldap/ldap_private_test.go +++ b/pkg/services/ldap/ldap_private_test.go @@ -10,7 +10,7 @@ import ( "gopkg.in/ldap.v3" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" ) @@ -159,7 +159,7 @@ func TestServer_validateGrafanaUser(t *testing.T) { log: logger.New("test"), } - user := &models.ExternalUserInfo{ + user := &login.ExternalUserInfo{ Login: "markelog", } @@ -179,7 +179,7 @@ func TestServer_validateGrafanaUser(t *testing.T) { log: logger.New("test"), } - user := &models.ExternalUserInfo{ + user := &login.ExternalUserInfo{ Login: "markelog", OrgRoles: map[int64]org.RoleType{ 1: "test", @@ -202,7 +202,7 @@ func TestServer_validateGrafanaUser(t *testing.T) { log: logger.New("test"), } - user := &models.ExternalUserInfo{ + user := &login.ExternalUserInfo{ Login: "markelog", } diff --git a/pkg/services/ldap/model.go b/pkg/services/ldap/model.go new file mode 100644 index 00000000000..302f5bffabe --- /dev/null +++ b/pkg/services/ldap/model.go @@ -0,0 +1,7 @@ +package ldap + +type TeamOrgGroupDTO struct { + TeamName string `json:"teamName"` + OrgName string `json:"orgName"` + GroupDN string `json:"groupDN"` +} diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 08b46cc7bfc..8443fc4f85a 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/web" @@ -38,7 +38,7 @@ func (l *LibraryElementService) registerAPIEndpoints() { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) response.Response { cmd := CreateLibraryElementCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -88,7 +88,7 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) response.Response { id, err := l.deleteLibraryElement(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { return toLibraryElementError(err, "Failed to delete library element") @@ -111,7 +111,7 @@ func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Res // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response { element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { return toLibraryElementError(err, "Failed to get library element") @@ -132,7 +132,7 @@ func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Respon // 200: getLibraryElementsResponse // 401: unauthorisedError // 500: internalServerError -func (l *LibraryElementService) getAllHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) getAllHandler(c *contextmodel.ReqContext) response.Response { query := searchLibraryElementsQuery{ perPage: c.QueryInt("perPage"), page: c.QueryInt("page"), @@ -166,7 +166,7 @@ func (l *LibraryElementService) getAllHandler(c *models.ReqContext) response.Res // 404: notFoundError // 412: preconditionFailedError // 500: internalServerError -func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) response.Response { cmd := PatchLibraryElementCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -213,7 +213,7 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (l *LibraryElementService) getConnectionsHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext) response.Response { connections, err := l.getConnections(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { return toLibraryElementError(err, "Failed to get connections") @@ -233,7 +233,7 @@ func (l *LibraryElementService) getConnectionsHandler(c *models.ReqContext) resp // 401: unauthorisedError // 404: notFoundError // 500: internalServerError -func (l *LibraryElementService) getByNameHandler(c *models.ReqContext) response.Response { +func (l *LibraryElementService) getByNameHandler(c *contextmodel.ReqContext) response.Response { elements, err := l.getLibraryElementsByName(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":name"]) if err != nil { return toLibraryElementError(err, "Failed to get library element") diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 16eddaa0a00..99f6f1d3057 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -243,7 +243,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed builder.Write(" INNER JOIN dashboard AS dashboard on le.folder_id = dashboard.id AND le.folder_id <> 0") writeParamSelectorSQL(&builder, params...) if signedInUser.OrgRole != org.RoleAdmin { - builder.WriteDashboardPermissionFilter(signedInUser, models.PERMISSION_VIEW) + builder.WriteDashboardPermissionFilter(signedInUser, dashboards.PERMISSION_VIEW) } builder.Write(` OR dashboard.id=0`) if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryElements); err != nil { @@ -360,7 +360,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI return err } if signedInUser.OrgRole != org.RoleAdmin { - builder.WriteDashboardPermissionFilter(signedInUser, models.PERMISSION_VIEW) + builder.WriteDashboardPermissionFilter(signedInUser, dashboards.PERMISSION_VIEW) } if query.sortDirection == search.SortAlphaDesc.Name { builder.Write(" ORDER BY 1 DESC") @@ -575,7 +575,7 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser * builder.Write(" INNER JOIN dashboard AS dashboard on lec.connection_id = dashboard.id") builder.Write(` WHERE lec.element_id=?`, element.ID) if signedInUser.OrgRole != org.RoleAdmin { - builder.WriteDashboardPermissionFilter(signedInUser, models.PERMISSION_VIEW) + builder.WriteDashboardPermissionFilter(signedInUser, dashboards.PERMISSION_VIEW) } if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryElementConnections); err != nil { return err diff --git a/pkg/services/libraryelements/libraryelements_permissions_test.go b/pkg/services/libraryelements/libraryelements_permissions_test.go index cca13cb678c..3e3c1b4e6dc 100644 --- a/pkg/services/libraryelements/libraryelements_permissions_test.go +++ b/pkg/services/libraryelements/libraryelements_permissions_test.go @@ -6,20 +6,22 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" - "github.com/stretchr/testify/require" ) func TestLibraryElementPermissions(t *testing.T) { var defaultPermissions = []folderACLItem{} - var adminOnlyPermissions = []folderACLItem{{org.RoleAdmin, models.PERMISSION_EDIT}} - var editorOnlyPermissions = []folderACLItem{{org.RoleEditor, models.PERMISSION_EDIT}} - var editorAndViewerPermissions = []folderACLItem{{org.RoleEditor, models.PERMISSION_EDIT}, {org.RoleViewer, models.PERMISSION_EDIT}} - var viewerOnlyPermissions = []folderACLItem{{org.RoleViewer, models.PERMISSION_EDIT}} - var everyonePermissions = []folderACLItem{{org.RoleAdmin, models.PERMISSION_EDIT}, {org.RoleEditor, models.PERMISSION_EDIT}, {org.RoleViewer, models.PERMISSION_EDIT}} - var noPermissions = []folderACLItem{{org.RoleViewer, models.PERMISSION_VIEW}} + var adminOnlyPermissions = []folderACLItem{{org.RoleAdmin, dashboards.PERMISSION_EDIT}} + var editorOnlyPermissions = []folderACLItem{{org.RoleEditor, dashboards.PERMISSION_EDIT}} + var editorAndViewerPermissions = []folderACLItem{{org.RoleEditor, dashboards.PERMISSION_EDIT}, {org.RoleViewer, dashboards.PERMISSION_EDIT}} + var viewerOnlyPermissions = []folderACLItem{{org.RoleViewer, dashboards.PERMISSION_EDIT}} + var everyonePermissions = []folderACLItem{{org.RoleAdmin, dashboards.PERMISSION_EDIT}, {org.RoleEditor, dashboards.PERMISSION_EDIT}, {org.RoleViewer, dashboards.PERMISSION_EDIT}} + var noPermissions = []folderACLItem{{org.RoleViewer, dashboards.PERMISSION_VIEW}} var folderCases = [][]folderACLItem{ defaultPermissions, adminOnlyPermissions, diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index c3d47e8d66f..79171ff888a 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -22,12 +22,14 @@ import ( "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/alerting" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -257,7 +259,7 @@ func getCreateCommandWithModel(folderID int64, name string, kind models.LibraryE type scenarioContext struct { ctx *web.Context service *LibraryElementService - reqContext *models.ReqContext + reqContext *contextmodel.ReqContext user user.SignedInUser folder *folder.Folder initialResult libraryElementResult @@ -266,7 +268,7 @@ type scenarioContext struct { type folderACLItem struct { roleType org.RoleType - permission models.PermissionType + permission dashboards.PermissionType } func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64) *dashboards.Dashboard { @@ -293,6 +295,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash service := dashboardservice.ProvideDashboardService( cfg, dashboardStore, dashboardStore, dashAlertExtractor, features, folderPermissions, dashboardPermissions, ac, + foldertest.NewFakeService(), ) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) @@ -437,6 +440,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo dashboardService := dashboardservice.ProvideDashboardService( sqlStore.Cfg, dashboardStore, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, + foldertest.NewFakeService(), ) guardian.InitLegacyGuardian(sqlStore, dashboardService, &teamtest.FakeService{}) service := LibraryElementService{ @@ -465,7 +469,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo ctx: &webCtx, service: &service, sqlStore: sqlStore, - reqContext: &models.ReqContext{ + reqContext: &contextmodel.ReqContext{ Context: &webCtx, SignedInUser: &usr, }, diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index a01af5e1fcf..c0ae1ba624c 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/org" @@ -77,7 +78,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -175,7 +176,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -221,7 +222,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) @@ -230,7 +231,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with unused/removed library panels, it should disconnect unused/removed library panels", func(t *testing.T, sc scenarioContext) { unused, err := sc.elementService.CreateElement(sc.ctx, sc.user, libraryelements.CreateLibraryElementCommand{ - FolderID: sc.folder.Id, + FolderID: sc.folder.ID, Name: "Unused Libray Panel", Model: []byte(` { @@ -277,7 +278,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -406,7 +407,7 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { _, err := sc.elementService.GetElement(sc.ctx, sc.user, existingUID) require.NoError(t, err) - err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.New(), panels, sc.folder.Id) + err = sc.service.ImportLibraryPanelsForDashboard(sc.ctx, sc.user, simplejson.New(), panels, sc.folder.ID) require.NoError(t, err) element, err := sc.elementService.GetElement(sc.ctx, sc.user, existingUID) @@ -414,7 +415,7 @@ func TestImportLibraryPanelsForDashboard(t *testing.T) { var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model) expected.FolderID = sc.initialResult.Result.FolderID expected.Description = sc.initialResult.Result.Description - expected.Meta.FolderUID = sc.folder.Uid + expected.Meta.FolderUID = sc.folder.UID expected.Meta.FolderName = sc.folder.Title var result = toLibraryElement(t, element) if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { @@ -601,14 +602,14 @@ type scenarioContext struct { service Service elementService libraryelements.Service user *user.SignedInUser - folder *models.Folder + folder *folder.Folder initialResult libraryPanelResult sqlStore db.DB } type folderACLItem struct { roleType org.RoleType - permission models.PermissionType + permission dashboards.PermissionType } func toLibraryElement(t *testing.T, res libraryelements.LibraryElementDTO) libraryElement { @@ -706,6 +707,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash service := dashboardservice.ProvideDashboardService( cfg, dashboardStore, dashboardStore, dashAlertService, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), acmock.NewMockedPermissionsService(), ac, + foldertest.NewFakeService(), ) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) @@ -766,19 +768,20 @@ func scenarioWithLibraryPanel(t *testing.T, desc string, fn func(t *testing.T, s store := dbtest.NewFakeDB() dashSvc := dashboards.NewFakeDashboardService(t) + var result *dashboards.Dashboard dashSvc.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ + result = &dashboards.Dashboard{ ID: q.ID, UID: q.UID, } - }).Return(nil) + }).Return(result, nil) guardian.InitLegacyGuardian(store, dashSvc, &teamtest.FakeService{}) t.Helper() testScenario(t, desc, func(t *testing.T, sc scenarioContext) { command := libraryelements.CreateLibraryElementCommand{ - FolderID: sc.folder.Id, + FolderID: sc.folder.ID, Name: "Text - Library Panel", Model: []byte(` { @@ -877,15 +880,15 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo sqlStore: sqlStore, } - folder := createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}) - sc.folder = &models.Folder{ - Id: folder.ID, - Uid: folder.UID, - Title: folder.Title, - Url: dashboards.GetFolderURL(folder.UID, slugify.Slugify(folder.Title)), + foldr := createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}) + sc.folder = &folder.Folder{ + ID: foldr.ID, + UID: foldr.UID, + Title: foldr.Title, + URL: dashboards.GetFolderURL(foldr.UID, slugify.Slugify(foldr.Title)), Version: 0, - Created: folder.Created, - Updated: folder.Updated, + Created: foldr.Created, + Updated: foldr.Updated, UpdatedBy: 0, CreatedBy: 0, HasACL: false, diff --git a/pkg/services/licensing/oss.go b/pkg/services/licensing/oss.go index 34c7fbe15e0..8139d63542d 100644 --- a/pkg/services/licensing/oss.go +++ b/pkg/services/licensing/oss.go @@ -2,7 +2,7 @@ package licensing import ( "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/setting" @@ -54,7 +54,7 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice Cfg: cfg, HooksService: hooksService, } - l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *models.ReqContext) { + l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *contextmodel.ReqContext) { if !req.IsGrafanaAdmin { return } diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go index 9614229b53e..d98b4ddf73d 100644 --- a/pkg/services/live/features/dashboard.go +++ b/pkg/services/live/features/dashboard.go @@ -67,12 +67,13 @@ func (h *DashboardHandler) OnSubscribe(ctx context.Context, user *user.SignedInU // make sure can view this dashboard if len(parts) == 2 && parts[0] == "uid" { query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: user.OrgID} - if err := h.DashboardService.GetDashboard(ctx, &query); err != nil { + queryResult, err := h.DashboardService.GetDashboard(ctx, &query) + if err != nil { logger.Error("Error getting dashboard", "query", query, "error", err) return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil } - dash := query.Result + dash := queryResult guard, err := guardian.NewByDashboard(ctx, dash, user.OrgID, user) if err != nil { return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err @@ -117,12 +118,13 @@ func (h *DashboardHandler) OnPublish(ctx context.Context, user *user.SignedInUse return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("ignore???") } query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: user.OrgID} - if err := h.DashboardService.GetDashboard(ctx, &query); err != nil { + queryResult, err := h.DashboardService.GetDashboard(ctx, &query) + if err != nil { logger.Error("Unknown dashboard", "query", query) return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil } - guard, err := guardian.NewByDashboard(ctx, query.Result, user.OrgID, user) + guard, err := guardian.NewByDashboard(ctx, queryResult, user.OrgID, user) if err != nil { logger.Error("Failed to create guardian", "err", err) return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index c763ccff7de..7954ac4df46 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -22,12 +22,12 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/comments/commentmodel" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -350,7 +350,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r CheckOrigin: checkOrigin, }) - g.websocketHandler = func(ctx *models.ReqContext) { + g.websocketHandler = func(ctx *contextmodel.ReqContext) { user := ctx.SignedInUser // Centrifuge expects Credentials in context with a current user ID. @@ -363,7 +363,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r wsHandler.ServeHTTP(ctx.Resp, r) } - g.pushWebsocketHandler = func(ctx *models.ReqContext) { + g.pushWebsocketHandler = func(ctx *contextmodel.ReqContext) { user := ctx.SignedInUser newCtx := livecontext.SetContextSignedUser(ctx.Req.Context(), user) newCtx = livecontext.SetContextStreamID(newCtx, web.Params(ctx.Req)[":streamId"]) @@ -371,7 +371,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r pushWSHandler.ServeHTTP(ctx.Resp, r) } - g.pushPipelineWebsocketHandler = func(ctx *models.ReqContext) { + g.pushPipelineWebsocketHandler = func(ctx *contextmodel.ReqContext) { user := ctx.SignedInUser newCtx := livecontext.SetContextSignedUser(ctx.Req.Context(), user) newCtx = livecontext.SetContextChannelID(newCtx, web.Params(ctx.Req)["*"]) @@ -971,7 +971,7 @@ func (g *GrafanaLive) ClientCount(orgID int64, channel string) (int, error) { return len(p.Presence), nil } -func (g *GrafanaLive) HandleHTTPPublish(ctx *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleHTTPPublish(ctx *contextmodel.ReqContext) response.Response { cmd := dtos.LivePublishCmd{} if err := web.Bind(ctx.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -1047,7 +1047,7 @@ type streamChannelListResponse struct { } // HandleListHTTP returns metadata so the UI can build a nice form -func (g *GrafanaLive) HandleListHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleListHTTP(c *contextmodel.ReqContext) response.Response { var channels []*managedstream.ManagedChannel var err error if g.IsHA() { @@ -1065,7 +1065,7 @@ func (g *GrafanaLive) HandleListHTTP(c *models.ReqContext) response.Response { } // HandleInfoHTTP special http response for -func (g *GrafanaLive) HandleInfoHTTP(ctx *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleInfoHTTP(ctx *contextmodel.ReqContext) response.Response { path := web.Params(ctx.Req)["*"] if path == "grafana/dashboards/gitops" { return response.JSON(http.StatusOK, util.DynMap{ @@ -1078,7 +1078,7 @@ func (g *GrafanaLive) HandleInfoHTTP(ctx *models.ReqContext) response.Response { } // HandleChannelRulesListHTTP ... -func (g *GrafanaLive) HandleChannelRulesListHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleChannelRulesListHTTP(c *contextmodel.ReqContext) response.Response { result, err := g.pipelineStorage.ListChannelRules(c.Req.Context(), c.OrgID) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to get channel rules", err) @@ -1139,7 +1139,7 @@ func (s *DryRunRuleStorage) ListChannelRules(_ context.Context, _ int64) ([]pipe } // HandlePipelineConvertTestHTTP ... -func (g *GrafanaLive) HandlePipelineConvertTestHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandlePipelineConvertTestHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1184,7 +1184,7 @@ func (g *GrafanaLive) HandlePipelineConvertTestHTTP(c *models.ReqContext) respon } // HandleChannelRulesPostHTTP ... -func (g *GrafanaLive) HandleChannelRulesPostHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleChannelRulesPostHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1204,7 +1204,7 @@ func (g *GrafanaLive) HandleChannelRulesPostHTTP(c *models.ReqContext) response. } // HandleChannelRulesPutHTTP ... -func (g *GrafanaLive) HandleChannelRulesPutHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleChannelRulesPutHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1227,7 +1227,7 @@ func (g *GrafanaLive) HandleChannelRulesPutHTTP(c *models.ReqContext) response.R } // HandleChannelRulesDeleteHTTP ... -func (g *GrafanaLive) HandleChannelRulesDeleteHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleChannelRulesDeleteHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1248,7 +1248,7 @@ func (g *GrafanaLive) HandleChannelRulesDeleteHTTP(c *models.ReqContext) respons } // HandlePipelineEntitiesListHTTP ... -func (g *GrafanaLive) HandlePipelineEntitiesListHTTP(_ *models.ReqContext) response.Response { +func (g *GrafanaLive) HandlePipelineEntitiesListHTTP(_ *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, util.DynMap{ "subscribers": pipeline.SubscribersRegistry, "dataOutputs": pipeline.DataOutputsRegistry, @@ -1259,7 +1259,7 @@ func (g *GrafanaLive) HandlePipelineEntitiesListHTTP(_ *models.ReqContext) respo } // HandleWriteConfigsListHTTP ... -func (g *GrafanaLive) HandleWriteConfigsListHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleWriteConfigsListHTTP(c *contextmodel.ReqContext) response.Response { backends, err := g.pipelineStorage.ListWriteConfigs(c.Req.Context(), c.OrgID) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to get write configs", err) @@ -1274,7 +1274,7 @@ func (g *GrafanaLive) HandleWriteConfigsListHTTP(c *models.ReqContext) response. } // HandleWriteConfigsPostHTTP ... -func (g *GrafanaLive) HandleWriteConfigsPostHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleWriteConfigsPostHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1294,7 +1294,7 @@ func (g *GrafanaLive) HandleWriteConfigsPostHTTP(c *models.ReqContext) response. } // HandleWriteConfigsPutHTTP ... -func (g *GrafanaLive) HandleWriteConfigsPutHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleWriteConfigsPutHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) @@ -1338,7 +1338,7 @@ func (g *GrafanaLive) HandleWriteConfigsPutHTTP(c *models.ReqContext) response.R } // HandleWriteConfigsDeleteHTTP ... -func (g *GrafanaLive) HandleWriteConfigsDeleteHTTP(c *models.ReqContext) response.Response { +func (g *GrafanaLive) HandleWriteConfigsDeleteHTTP(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(http.StatusInternalServerError, "Error reading body", err) diff --git a/pkg/services/live/pushhttp/push.go b/pkg/services/live/pushhttp/push.go index 61291cd7e4f..302a5665c28 100644 --- a/pkg/services/live/pushhttp/push.go +++ b/pkg/services/live/pushhttp/push.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/live/convert" "github.com/grafana/grafana/pkg/services/live/pushurl" @@ -45,7 +45,7 @@ func (g *Gateway) Run(ctx context.Context) error { return ctx.Err() } -func (g *Gateway) Handle(ctx *models.ReqContext) { +func (g *Gateway) Handle(ctx *contextmodel.ReqContext) { streamID := web.Params(ctx.Req)[":streamId"] stream, err := g.GrafanaLive.ManagedStreamRunner.GetOrCreateStream(ctx.SignedInUser.OrgID, liveDto.ScopeStream, streamID) @@ -98,7 +98,7 @@ func (g *Gateway) Handle(ctx *models.ReqContext) { ctx.Resp.WriteHeader(http.StatusOK) } -func (g *Gateway) HandlePipelinePush(ctx *models.ReqContext) { +func (g *Gateway) HandlePipelinePush(ctx *contextmodel.ReqContext) { channelID := web.Params(ctx.Req)["*"] body, err := io.ReadAll(ctx.Req.Body) diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index c88aa92c7dd..9f7e8908533 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -3,17 +3,16 @@ package login import ( "context" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/user" ) type AuthInfoService interface { - LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*user.User, error) - GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error - GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) - GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error - SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error - UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error + LookupAndUpdate(ctx context.Context, query *GetUserByAuthInfoQuery) (*user.User, error) + GetAuthInfo(ctx context.Context, query *GetAuthInfoQuery) error + GetUserLabels(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) + GetExternalUserInfoByLogin(ctx context.Context, query *GetExternalUserInfoByLoginQuery) error + SetAuthInfo(ctx context.Context, cmd *SetAuthInfoCommand) error + UpdateAuthInfo(ctx context.Context, cmd *UpdateAuthInfoCommand) error DeleteUserAuthInfo(ctx context.Context, userID int64) error } @@ -22,6 +21,7 @@ const ( LDAPAuthModule = "ldap" AuthProxyAuthModule = "authproxy" JWTModule = "jwt" + RenderModule = "render" ) func GetAuthProviderLabel(authModule string) string { diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index 1aa23b77d17..1d4abbfe85b 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/user" @@ -35,19 +34,19 @@ func ProvideAuthInfoStore(sqlStore db.DB, secretsService secrets.Service, userSe return store } -func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { +func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *login.GetExternalUserInfoByLoginQuery) error { userQuery := user.GetUserByLoginQuery{LoginOrEmail: query.LoginOrEmail} usr, err := s.userService.GetByLogin(ctx, &userQuery) if err != nil { return err } - authInfoQuery := &models.GetAuthInfoQuery{UserId: usr.ID} + authInfoQuery := &login.GetAuthInfoQuery{UserId: usr.ID} if err := s.GetAuthInfo(ctx, authInfoQuery); err != nil { return err } - query.Result = &models.ExternalUserInfo{ + query.Result = &login.ExternalUserInfo{ UserId: usr.ID, Login: usr.Login, Email: usr.Email, @@ -59,12 +58,12 @@ func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *m return nil } -func (s *AuthInfoStore) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { +func (s *AuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) error { if query.UserId == 0 && query.AuthId == "" { return user.ErrUserNotFound } - userAuth := &models.UserAuth{ + userAuth := &login.UserAuth{ UserId: query.UserId, AuthModule: query.AuthModule, AuthId: query.AuthId, @@ -110,8 +109,8 @@ func (s *AuthInfoStore) GetAuthInfo(ctx context.Context, query *models.GetAuthIn return nil } -func (s *AuthInfoStore) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { - userAuths := []models.UserAuth{} +func (s *AuthInfoStore) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + userAuths := []login.UserAuth{} params := make([]interface{}, 0, len(query.UserIDs)) for _, id := range query.UserIDs { params = append(params, id) @@ -134,8 +133,8 @@ func (s *AuthInfoStore) GetUserLabels(ctx context.Context, query models.GetUserL return labelMap, nil } -func (s *AuthInfoStore) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { - authUser := &models.UserAuth{ +func (s *AuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { + authUser := &login.UserAuth{ UserId: cmd.UserId, AuthModule: cmd.AuthModule, AuthId: cmd.AuthId, @@ -179,10 +178,10 @@ func (s *AuthInfoStore) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfo // UpdateAuthInfoDate updates the auth info for the user with the latest date. // Avoids overlapping entries hiding the last used one (ex: LDAP->SAML->LDAP). -func (s *AuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *models.UserAuth) error { +func (s *AuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *login.UserAuth) error { authInfo.Created = GetTime() - cond := &models.UserAuth{ + cond := &login.UserAuth{ Id: authInfo.Id, UserId: authInfo.UserId, AuthModule: authInfo.AuthModule, @@ -193,8 +192,8 @@ func (s *AuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *models }) } -func (s *AuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { - authUser := &models.UserAuth{ +func (s *AuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { + authUser := &login.UserAuth{ UserId: cmd.UserId, AuthModule: cmd.AuthModule, AuthId: cmd.AuthId, @@ -237,7 +236,7 @@ func (s *AuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAu }) } -func (s *AuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAuthInfoCommand) error { +func (s *AuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Delete(cmd.UserAuth) return err diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index 1f248453eb5..e0d6f440994 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" ) @@ -31,8 +30,8 @@ func ProvideAuthInfoService(userProtectionService login.UserProtectionService, a return s } -func (s *Implementation) LookupAndFix(ctx context.Context, query *models.GetUserByAuthInfoQuery) (bool, *user.User, *models.UserAuth, error) { - authQuery := &models.GetAuthInfoQuery{} +func (s *Implementation) LookupAndFix(ctx context.Context, query *login.GetUserByAuthInfoQuery) (bool, *user.User, *login.UserAuth, error) { + authQuery := &login.GetAuthInfoQuery{} // Try to find the user by auth module and id first if query.AuthModule != "" && query.AuthId != "" { @@ -49,7 +48,7 @@ func (s *Implementation) LookupAndFix(ctx context.Context, query *models.GetUser if query.UserLookupParams.UserID != nil && *query.UserLookupParams.UserID != 0 && *query.UserLookupParams.UserID != authQuery.Result.UserId { - if err := s.authInfoStore.DeleteAuthInfo(ctx, &models.DeleteAuthInfoCommand{ + if err := s.authInfoStore.DeleteAuthInfo(ctx, &login.DeleteAuthInfoCommand{ UserAuth: authQuery.Result, }); err != nil { s.logger.Error("Error removing user_auth entry", "error", err) @@ -61,7 +60,7 @@ func (s *Implementation) LookupAndFix(ctx context.Context, query *models.GetUser if err != nil { if errors.Is(err, user.ErrUserNotFound) { // if the user has been deleted then remove the entry - if errDel := s.authInfoStore.DeleteAuthInfo(ctx, &models.DeleteAuthInfoCommand{ + if errDel := s.authInfoStore.DeleteAuthInfo(ctx, &login.DeleteAuthInfoCommand{ UserAuth: authQuery.Result, }); errDel != nil { s.logger.Error("Error removing user_auth entry", "error", errDel) @@ -81,7 +80,7 @@ func (s *Implementation) LookupAndFix(ctx context.Context, query *models.GetUser return false, nil, nil, user.ErrUserNotFound } -func (s *Implementation) LookupByOneOf(ctx context.Context, params *models.UserLookupParams) (*user.User, error) { +func (s *Implementation) LookupByOneOf(ctx context.Context, params *login.UserLookupParams) (*user.User, error) { var usr *user.User var err error @@ -116,9 +115,9 @@ func (s *Implementation) LookupByOneOf(ctx context.Context, params *models.UserL return usr, nil } -func (s *Implementation) GenericOAuthLookup(ctx context.Context, authModule string, authId string, userID int64) (*models.UserAuth, error) { +func (s *Implementation) GenericOAuthLookup(ctx context.Context, authModule string, authId string, userID int64) (*login.UserAuth, error) { if authModule == genericOAuthModule && userID != 0 { - authQuery := &models.GetAuthInfoQuery{} + authQuery := &login.GetAuthInfoQuery{} authQuery.AuthModule = authModule authQuery.AuthId = authId authQuery.UserId = userID @@ -132,7 +131,7 @@ func (s *Implementation) GenericOAuthLookup(ctx context.Context, authModule stri return nil, nil } -func (s *Implementation) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*user.User, error) { +func (s *Implementation) LookupAndUpdate(ctx context.Context, query *login.GetUserByAuthInfoQuery) (*user.User, error) { // 1. LookupAndFix = auth info, user, error // TODO: Not a big fan of the fact that we are deleting auth info here, might want to move that foundUser, usr, authInfo, err := s.LookupAndFix(ctx, query) @@ -165,7 +164,7 @@ func (s *Implementation) LookupAndUpdate(ctx context.Context, query *models.GetU if query.AuthModule != "" { if authInfo == nil { - cmd := &models.SetAuthInfoCommand{ + cmd := &login.SetAuthInfoCommand{ UserId: usr.ID, AuthModule: query.AuthModule, AuthId: query.AuthId, @@ -183,26 +182,26 @@ func (s *Implementation) LookupAndUpdate(ctx context.Context, query *models.GetU return usr, nil } -func (s *Implementation) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { +func (s *Implementation) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) error { return s.authInfoStore.GetAuthInfo(ctx, query) } -func (s *Implementation) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { +func (s *Implementation) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { if len(query.UserIDs) == 0 { return map[int64]string{}, nil } return s.authInfoStore.GetUserLabels(ctx, query) } -func (s *Implementation) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { +func (s *Implementation) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { return s.authInfoStore.UpdateAuthInfo(ctx, cmd) } -func (s *Implementation) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { +func (s *Implementation) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { return s.authInfoStore.SetAuthInfo(ctx, cmd) } -func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { +func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query *login.GetExternalUserInfoByLoginQuery) error { return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query) } diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index f22814020a5..d61e8191482 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -50,22 +49,22 @@ func TestUserAuth(t *testing.T) { t.Run("Can find existing user", func(t *testing.T) { // By Login - login := "loginuser0" + userlogin := "loginuser0" authInfoStore.ExpectedUser = &user.User{ Login: "loginuser0", ID: 1, Email: "user1@test.com", } - query := &models.GetUserByAuthInfoQuery{UserLookupParams: models.UserLookupParams{Login: &login}} + query := &login.GetUserByAuthInfoQuery{UserLookupParams: login.UserLookupParams{Login: &userlogin}} usr, err := srv.LookupAndUpdate(context.Background(), query) require.Nil(t, err) - require.Equal(t, usr.Login, login) + require.Equal(t, usr.Login, userlogin) // By ID id := usr.ID - usr, err = srv.LookupByOneOf(context.Background(), &models.UserLookupParams{ + usr, err = srv.LookupByOneOf(context.Background(), &login.UserLookupParams{ UserID: &id, }) @@ -75,7 +74,7 @@ func TestUserAuth(t *testing.T) { // By Email email := "user1@test.com" - usr, err = srv.LookupByOneOf(context.Background(), &models.UserLookupParams{ + usr, err = srv.LookupByOneOf(context.Background(), &login.UserLookupParams{ Email: &email, }) @@ -86,7 +85,7 @@ func TestUserAuth(t *testing.T) { // Don't find nonexistent user email = "nonexistent@test.com" - usr, err = srv.LookupByOneOf(context.Background(), &models.UserLookupParams{ + usr, err = srv.LookupByOneOf(context.Background(), &login.UserLookupParams{ Email: &email, }) @@ -98,29 +97,29 @@ func TestUserAuth(t *testing.T) { // get nonexistent user_auth entry authInfoStore.ExpectedUser = &user.User{} authInfoStore.ExpectedError = user.ErrUserNotFound - query := &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + query := &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} usr, err := srv.LookupAndUpdate(context.Background(), query) require.Equal(t, user.ErrUserNotFound, err) require.Nil(t, usr) // create user_auth entry - login := "loginuser0" + userlogin := "loginuser0" authInfoStore.ExpectedUser = &user.User{Login: "loginuser0", ID: 1, Email: ""} authInfoStore.ExpectedError = nil - authInfoStore.ExpectedOAuth = &models.UserAuth{Id: 1} - query.UserLookupParams.Login = &login + authInfoStore.ExpectedOAuth = &login.UserAuth{Id: 1} + query.UserLookupParams.Login = &userlogin usr, err = srv.LookupAndUpdate(context.Background(), query) require.Nil(t, err) - require.Equal(t, usr.Login, login) + require.Equal(t, usr.Login, userlogin) // get via user_auth - query = &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + query = &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} usr, err = srv.LookupAndUpdate(context.Background(), query) require.Nil(t, err) - require.Equal(t, usr.Login, login) + require.Equal(t, usr.Login, userlogin) // get with non-matching id idPlusOne := usr.ID + 1 @@ -133,7 +132,7 @@ func TestUserAuth(t *testing.T) { require.Equal(t, usr.Login, "loginuser1") // get via user_auth - query = &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + query = &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} usr, err = srv.LookupAndUpdate(context.Background(), query) require.Nil(t, err) @@ -149,7 +148,7 @@ func TestUserAuth(t *testing.T) { authInfoStore.ExpectedUser = nil authInfoStore.ExpectedError = user.ErrUserNotFound // get via user_auth for deleted user - query = &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} + query = &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test"} usr, err = srv.LookupAndUpdate(context.Background(), query) require.Equal(t, err, user.ErrUserNotFound) @@ -167,10 +166,10 @@ func TestUserAuth(t *testing.T) { token = token.WithExtra(map[string]interface{}{"id_token": idToken}) // Find a user to set tokens on - login := "loginuser0" + userlogin := "loginuser0" authInfoStore.ExpectedUser = &user.User{Login: "loginuser0", ID: 1, Email: ""} authInfoStore.ExpectedError = nil - authInfoStore.ExpectedOAuth = &models.UserAuth{ + authInfoStore.ExpectedOAuth = &login.UserAuth{ Id: 1, OAuthAccessToken: token.AccessToken, OAuthRefreshToken: token.RefreshToken, @@ -179,15 +178,15 @@ func TestUserAuth(t *testing.T) { OAuthExpiry: token.Expiry, } // Calling GetUserByAuthInfoQuery on an existing user will populate an entry in the user_auth table - query := &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test", UserLookupParams: models.UserLookupParams{ - Login: &login, + query := &login.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err := srv.LookupAndUpdate(context.Background(), query) require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) - cmd := &models.UpdateAuthInfoCommand{ + cmd := &login.UpdateAuthInfoCommand{ UserId: user.ID, AuthId: query.AuthId, AuthModule: query.AuthModule, @@ -197,7 +196,7 @@ func TestUserAuth(t *testing.T) { require.Nil(t, err) - getAuthQuery := &models.GetAuthInfoQuery{ + getAuthQuery := &login.GetAuthInfoQuery{ UserId: user.ID, } @@ -230,34 +229,34 @@ func TestUserAuth(t *testing.T) { } // Find a user to set tokens on - login := "loginuser0" + userlogin := "loginuser0" // Calling srv.LookupAndUpdateQuery on an existing user will populate an entry in the user_auth table // Make the first log-in during the past database.GetTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query := &models.GetUserByAuthInfoQuery{AuthModule: "test1", AuthId: "test1", UserLookupParams: models.UserLookupParams{ - Login: &login, + query := &login.GetUserByAuthInfoQuery{AuthModule: "test1", AuthId: "test1", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err := srv.LookupAndUpdate(context.Background(), query) database.GetTime = time.Now require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) // Add a second auth module for this user // Have this module's last log-in be more recent database.GetTime = func() time.Time { return time.Now().AddDate(0, 0, -1) } - query = &models.GetUserByAuthInfoQuery{AuthModule: "test2", AuthId: "test2", UserLookupParams: models.UserLookupParams{ - Login: &login, + query = &login.GetUserByAuthInfoQuery{AuthModule: "test2", AuthId: "test2", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err = srv.LookupAndUpdate(context.Background(), query) database.GetTime = time.Now require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) authInfoStore.ExpectedOAuth.AuthModule = "test2" // Get the latest entry by not supply an authmodule or authid - getAuthQuery := &models.GetAuthInfoQuery{ + getAuthQuery := &login.GetAuthInfoQuery{ UserId: user.ID, } @@ -267,13 +266,13 @@ func TestUserAuth(t *testing.T) { require.Equal(t, getAuthQuery.Result.AuthModule, "test2") // "log in" again with the first auth module - updateAuthCmd := &models.UpdateAuthInfoCommand{UserId: user.ID, AuthModule: "test1", AuthId: "test1"} + updateAuthCmd := &login.UpdateAuthInfoCommand{UserId: user.ID, AuthModule: "test1", AuthId: "test1"} err = authInfoStore.UpdateAuthInfo(context.Background(), updateAuthCmd) require.Nil(t, err) authInfoStore.ExpectedOAuth.AuthModule = "test1" // Get the latest entry by not supply an authmodule or authid - getAuthQuery = &models.GetAuthInfoQuery{ + getAuthQuery = &login.GetAuthInfoQuery{ UserId: user.ID, } @@ -303,33 +302,33 @@ func TestUserAuth(t *testing.T) { } // Find a user to set tokens on - login := "loginuser0" + userlogin := "loginuser0" fixedTime := time.Now() // Calling srv.LookupAndUpdateQuery on an existing user will populate an entry in the user_auth table // Make the first log-in during the past database.GetTime = func() time.Time { return fixedTime.AddDate(0, 0, -2) } - queryOne := &models.GetUserByAuthInfoQuery{AuthModule: "test1", AuthId: "test1", UserLookupParams: models.UserLookupParams{ - Login: &login, + queryOne := &login.GetUserByAuthInfoQuery{AuthModule: "test1", AuthId: "test1", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err := srv.LookupAndUpdate(context.Background(), queryOne) database.GetTime = time.Now require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) // Add a second auth module for this user // Have this module's last log-in be more recent database.GetTime = func() time.Time { return fixedTime.AddDate(0, 0, -1) } - queryTwo := &models.GetUserByAuthInfoQuery{AuthModule: "test2", AuthId: "test2", UserLookupParams: models.UserLookupParams{ - Login: &login, + queryTwo := &login.GetUserByAuthInfoQuery{AuthModule: "test2", AuthId: "test2", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err = srv.LookupAndUpdate(context.Background(), queryTwo) require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) // Get the latest entry by not supply an authmodule or authid - getAuthQuery := &models.GetAuthInfoQuery{ + getAuthQuery := &login.GetAuthInfoQuery{ UserId: user.ID, } authInfoStore.ExpectedOAuth.AuthModule = "test2" @@ -343,7 +342,7 @@ func TestUserAuth(t *testing.T) { database.GetTime = func() time.Time { return fixedTime } // add oauth info to auth_info to make sure update date does not overwrite it - updateAuthCmd := &models.UpdateAuthInfoCommand{UserId: user.ID, AuthModule: "test1", AuthId: "test1", OAuthToken: &oauth2.Token{ + updateAuthCmd := &login.UpdateAuthInfoCommand{UserId: user.ID, AuthModule: "test1", AuthId: "test1", OAuthToken: &oauth2.Token{ AccessToken: "access_token", TokenType: "token_type", RefreshToken: "refresh_token", @@ -354,7 +353,7 @@ func TestUserAuth(t *testing.T) { user, err = srv.LookupAndUpdate(context.Background(), queryOne) require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) authInfoStore.ExpectedOAuth.AuthModule = "test1" authInfoStore.ExpectedOAuth.OAuthAccessToken = "access_token" err = authInfoStore.GetAuthInfo(context.Background(), getAuthQuery) @@ -368,7 +367,7 @@ func TestUserAuth(t *testing.T) { database.GetTime = func() time.Time { return fixedTime.AddDate(0, 0, 1) } user, err = srv.LookupAndUpdate(context.Background(), queryTwo) require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) authInfoStore.ExpectedOAuth.AuthModule = "test2" err = authInfoStore.GetAuthInfo(context.Background(), getAuthQuery) @@ -376,7 +375,7 @@ func TestUserAuth(t *testing.T) { require.Equal(t, "test2", getAuthQuery.Result.AuthModule) // Ensure test 1 did not have its entry modified - getAuthQueryUnchanged := &models.GetAuthInfoQuery{ + getAuthQueryUnchanged := &login.GetAuthInfoQuery{ UserId: user.ID, AuthModule: "test1", } @@ -389,23 +388,23 @@ func TestUserAuth(t *testing.T) { t.Run("Can set & locate by generic oauth auth module and user id", func(t *testing.T) { // Find a user to set tokens on - login := "loginuser0" + userlogin := "loginuser0" // Expect to pass since there's a matching login user database.GetTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query := &models.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: models.UserLookupParams{ - Login: &login, + query := &login.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: login.UserLookupParams{ + Login: &userlogin, }} user, err := srv.LookupAndUpdate(context.Background(), query) database.GetTime = time.Now require.Nil(t, err) - require.Equal(t, user.Login, login) + require.Equal(t, user.Login, userlogin) otherLoginUser := "aloginuser" // Should throw a "user not found" error since there's no matching login user database.GetTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query = &models.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: models.UserLookupParams{ + query = &login.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: login.UserLookupParams{ Login: &otherLoginUser, }} authInfoStore.ExpectedError = errors.New("some error") @@ -510,7 +509,7 @@ type FakeAuthInfoStore struct { login.AuthInfoService ExpectedError error ExpectedUser *user.User - ExpectedOAuth *models.UserAuth + ExpectedOAuth *login.UserAuth ExpectedDuplicateUserEntries int ExpectedHasDuplicateUserEntries int ExpectedLoginStats login.LoginStats @@ -520,23 +519,23 @@ func newFakeAuthInfoStore() *FakeAuthInfoStore { return &FakeAuthInfoStore{} } -func (f *FakeAuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { +func (f *FakeAuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *login.GetExternalUserInfoByLoginQuery) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { +func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) error { query.Result = f.ExpectedOAuth return f.ExpectedError } -func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { +func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *models.UserAuth) error { +func (f *FakeAuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *login.UserAuth) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { +func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAuthInfoCommand) error { +func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { return f.ExpectedError } func (f *FakeAuthInfoStore) GetUserById(ctx context.Context, id int64) (*user.User, error) { diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go index bb1ff508e4a..908d1c43038 100644 --- a/pkg/services/login/login.go +++ b/pkg/services/login/login.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/user" ) @@ -15,10 +14,10 @@ var ( ErrSignupNotAllowed = errors.New("system administrator has disabled signup") ) -type TeamSyncFunc func(user *user.User, externalUser *models.ExternalUserInfo) error +type TeamSyncFunc func(user *user.User, externalUser *ExternalUserInfo) error type Service interface { - UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error + UpsertUser(ctx context.Context, cmd *UpsertUserCommand) error DisableExternalUser(ctx context.Context, username string) error SetTeamSyncFunc(TeamSyncFunc) } diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 37f38af1373..0b18e713136 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -5,7 +5,6 @@ import ( "errors" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" @@ -44,10 +43,10 @@ type Implementation struct { } // UpsertUser updates an existing user, or if it doesn't exist, inserts a new one. -func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { +func (ls *Implementation) UpsertUser(ctx context.Context, cmd *login.UpsertUserCommand) error { extUser := cmd.ExternalUser - usr, errAuthLookup := ls.AuthInfoService.LookupAndUpdate(ctx, &models.GetUserByAuthInfoQuery{ + usr, errAuthLookup := ls.AuthInfoService.LookupAndUpdate(ctx, &login.GetUserByAuthInfoQuery{ AuthModule: extUser.AuthModule, AuthId: extUser.AuthId, UserLookupParams: cmd.UserLookupParams, @@ -109,7 +108,7 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser } if extUser.AuthModule != "" { - cmd2 := &models.SetAuthInfoCommand{ + cmd2 := &login.SetAuthInfoCommand{ UserId: cmd.Result.ID, AuthModule: extUser.AuthModule, AuthId: extUser.AuthId, @@ -154,7 +153,8 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser } } - if ls.TeamSync != nil { + // There are external providers where we want to completely skip team synchronization see - https://github.com/grafana/grafana/issues/62175 + if ls.TeamSync != nil && !extUser.SkipTeamSync { if errTeamSync := ls.TeamSync(cmd.Result, extUser); errTeamSync != nil { return errTeamSync } @@ -165,7 +165,7 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser func (ls *Implementation) DisableExternalUser(ctx context.Context, username string) error { // Check if external user exist in Grafana - userQuery := &models.GetExternalUserInfoByLoginQuery{ + userQuery := &login.GetExternalUserInfoByLoginQuery{ LoginOrEmail: username, } @@ -208,7 +208,7 @@ func (ls *Implementation) SetTeamSyncFunc(teamSyncFunc login.TeamSyncFunc) { ls.TeamSync = teamSyncFunc } -func (ls *Implementation) updateUser(ctx context.Context, usr *user.User, extUser *models.ExternalUserInfo) error { +func (ls *Implementation) updateUser(ctx context.Context, usr *user.User, extUser *login.ExternalUserInfo) error { // sync user info updateCmd := &user.UpdateUserCommand{ UserID: usr.ID, @@ -241,8 +241,8 @@ func (ls *Implementation) updateUser(ctx context.Context, usr *user.User, extUse return ls.userService.Update(ctx, updateCmd) } -func (ls *Implementation) updateUserAuth(ctx context.Context, user *user.User, extUser *models.ExternalUserInfo) error { - updateCmd := &models.UpdateAuthInfoCommand{ +func (ls *Implementation) updateUserAuth(ctx context.Context, user *user.User, extUser *login.ExternalUserInfo) error { + updateCmd := &login.UpdateAuthInfoCommand{ AuthModule: extUser.AuthModule, AuthId: extUser.AuthId, UserId: user.ID, @@ -253,7 +253,7 @@ func (ls *Implementation) updateUserAuth(ctx context.Context, user *user.User, e return ls.AuthInfoService.UpdateAuthInfo(ctx, updateCmd) } -func (ls *Implementation) syncOrgRoles(ctx context.Context, usr *user.User, extUser *models.ExternalUserInfo) error { +func (ls *Implementation) syncOrgRoles(ctx context.Context, usr *user.User, extUser *login.ExternalUserInfo) error { logger.Debug("Syncing organization roles", "id", usr.ID, "extOrgRoles", extUser.OrgRoles) // don't sync org roles if none is specified diff --git a/pkg/services/login/loginservice/loginservice_mock.go b/pkg/services/login/loginservice/loginservice_mock.go index b77ac1b4caa..dbb39e0fcf6 100644 --- a/pkg/services/login/loginservice/loginservice_mock.go +++ b/pkg/services/login/loginservice/loginservice_mock.go @@ -3,7 +3,6 @@ package loginservice import ( "context" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" ) @@ -11,11 +10,11 @@ import ( type LoginServiceMock struct { login.Service ExpectedUser *user.User - ExpectedUserFunc func(cmd *models.UpsertUserCommand) *user.User + ExpectedUserFunc func(cmd *login.UpsertUserCommand) *user.User ExpectedError error } -func (s LoginServiceMock) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { +func (s LoginServiceMock) UpsertUser(ctx context.Context, cmd *login.UpsertUserCommand) error { if s.ExpectedUserFunc != nil { cmd.Result = s.ExpectedUserFunc(cmd) return s.ExpectedError diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index 0798d744045..626c3f519b6 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -8,7 +8,9 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/logintest" @@ -17,8 +19,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) { @@ -65,14 +65,14 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { func Test_teamSync(t *testing.T) { authInfoMock := &logintest.AuthInfoServiceFake{} - login := Implementation{ + loginsvc := Implementation{ QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, } email := "test_user@example.org" - upserCmd := &models.UpsertUserCommand{ExternalUser: &models.ExternalUserInfo{Email: email}, - UserLookupParams: models.UserLookupParams{Email: &email}} + upsertCmd := &login.UpsertUserCommand{ExternalUser: &login.ExternalUserInfo{Email: email}, + UserLookupParams: login.UserLookupParams{Email: &email}} expectedUser := &user.User{ ID: 1, Email: email, @@ -82,33 +82,56 @@ func Test_teamSync(t *testing.T) { authInfoMock.ExpectedUser = expectedUser var actualUser *user.User - var actualExternalUser *models.ExternalUserInfo + var actualExternalUser *login.ExternalUserInfo - t.Run("login.TeamSync should not be called when nil", func(t *testing.T) { - err := login.UpsertUser(context.Background(), upserCmd) + t.Run("login.TeamSync should not be called when nil", func(t *testing.T) { + err := loginsvc.UpsertUser(context.Background(), upsertCmd) require.Nil(t, err) assert.Nil(t, actualUser) assert.Nil(t, actualExternalUser) t.Run("login.TeamSync should be called when not nil", func(t *testing.T) { - teamSyncFunc := func(user *user.User, externalUser *models.ExternalUserInfo) error { + teamSyncFunc := func(user *user.User, externalUser *login.ExternalUserInfo) error { actualUser = user actualExternalUser = externalUser return nil } - login.TeamSync = teamSyncFunc - err := login.UpsertUser(context.Background(), upserCmd) + loginsvc.TeamSync = teamSyncFunc + err := loginsvc.UpsertUser(context.Background(), upsertCmd) require.Nil(t, err) assert.Equal(t, actualUser, expectedUser) - assert.Equal(t, actualExternalUser, upserCmd.ExternalUser) + assert.Equal(t, actualExternalUser, upsertCmd.ExternalUser) + }) + + t.Run("login.TeamSync should not be called when not nil and skipTeamSync is set for externalUserInfo", func(t *testing.T) { + var actualUser *user.User + var actualExternalUser *login.ExternalUserInfo + upsertCmdSkipTeamSync := &login.UpsertUserCommand{ + ExternalUser: &login.ExternalUserInfo{ + Email: email, + // sending in ExternalUserInfo with SkipTeamSync yields no team sync + SkipTeamSync: true, + }, + UserLookupParams: login.UserLookupParams{Email: &email}, + } + teamSyncFunc := func(user *user.User, externalUser *login.ExternalUserInfo) error { + actualUser = user + actualExternalUser = externalUser + return nil + } + loginsvc.TeamSync = teamSyncFunc + err := loginsvc.UpsertUser(context.Background(), upsertCmdSkipTeamSync) + require.Nil(t, err) + assert.Nil(t, actualUser) + assert.Nil(t, actualExternalUser) }) t.Run("login.TeamSync should propagate its errors to the caller", func(t *testing.T) { - teamSyncFunc := func(user *user.User, externalUser *models.ExternalUserInfo) error { + teamSyncFunc := func(user *user.User, externalUser *login.ExternalUserInfo) error { return errors.New("teamsync test error") } - login.TeamSync = teamSyncFunc - err := login.UpsertUser(context.Background(), upserCmd) + loginsvc.TeamSync = teamSyncFunc + err := loginsvc.UpsertUser(context.Background(), upsertCmd) require.Error(t, err) }) }) @@ -143,8 +166,8 @@ func createUserOrgDTO() []*org.UserOrgDTO { return users } -func createSimpleExternalUser() models.ExternalUserInfo { - externalUser := models.ExternalUserInfo{ +func createSimpleExternalUser() login.ExternalUserInfo { + externalUser := login.ExternalUserInfo{ AuthModule: login.LDAPAuthModule, OrgRoles: map[int64]org.RoleType{ 1: org.RoleViewer, diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index 1ca3d532ca9..0c9c2b82944 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -3,14 +3,13 @@ package logintest import ( "context" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" ) type LoginServiceFake struct{} -func (l *LoginServiceFake) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { +func (l *LoginServiceFake) UpsertUser(ctx context.Context, cmd *login.UpsertUserCommand) error { return nil } func (l *LoginServiceFake) DisableExternalUser(ctx context.Context, username string) error { @@ -21,17 +20,17 @@ func (l *LoginServiceFake) SetTeamSyncFunc(login.TeamSyncFunc) {} type AuthInfoServiceFake struct { login.AuthInfoService LatestUserID int64 - ExpectedUserAuth *models.UserAuth + ExpectedUserAuth *login.UserAuth ExpectedUser *user.User - ExpectedExternalUser *models.ExternalUserInfo + ExpectedExternalUser *login.ExternalUserInfo ExpectedError error ExpectedLabels map[int64]string - SetAuthInfoFn func(ctx context.Context, cmd *models.SetAuthInfoCommand) error - UpdateAuthInfoFn func(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error + SetAuthInfoFn func(ctx context.Context, cmd *login.SetAuthInfoCommand) error + UpdateAuthInfoFn func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error } -func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*user.User, error) { +func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *login.GetUserByAuthInfoQuery) (*user.User, error) { if query.UserLookupParams.UserID != nil { a.LatestUserID = *query.UserLookupParams.UserID } else { @@ -40,17 +39,17 @@ func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *models return a.ExpectedUser, a.ExpectedError } -func (a *AuthInfoServiceFake) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { +func (a *AuthInfoServiceFake) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) error { a.LatestUserID = query.UserId query.Result = a.ExpectedUserAuth return a.ExpectedError } -func (a *AuthInfoServiceFake) GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) { +func (a *AuthInfoServiceFake) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { return a.ExpectedLabels, a.ExpectedError } -func (a *AuthInfoServiceFake) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { +func (a *AuthInfoServiceFake) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { if a.SetAuthInfoFn != nil { return a.SetAuthInfoFn(ctx, cmd) } @@ -58,7 +57,7 @@ func (a *AuthInfoServiceFake) SetAuthInfo(ctx context.Context, cmd *models.SetAu return a.ExpectedError } -func (a *AuthInfoServiceFake) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { +func (a *AuthInfoServiceFake) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { if a.UpdateAuthInfoFn != nil { return a.UpdateAuthInfoFn(ctx, cmd) } @@ -66,7 +65,7 @@ func (a *AuthInfoServiceFake) UpdateAuthInfo(ctx context.Context, cmd *models.Up return a.ExpectedError } -func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { +func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, query *login.GetExternalUserInfoByLoginQuery) error { query.Result = a.ExpectedExternalUser return a.ExpectedError } @@ -80,7 +79,7 @@ type AuthenticatorFake struct { ExpectedError error } -func (a *AuthenticatorFake) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { +func (a *AuthenticatorFake) AuthenticateUser(c context.Context, query *login.LoginUserQuery) error { query.User = a.ExpectedUser return a.ExpectedError } diff --git a/pkg/services/login/model.go b/pkg/services/login/model.go index a69b7d9fcba..60a3526872b 100644 --- a/pkg/services/login/model.go +++ b/pkg/services/login/model.go @@ -1,10 +1,17 @@ package login import ( + "fmt" "sync" "time" "github.com/prometheus/client_golang/prometheus" + "golang.org/x/oauth2" + + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) type LoginStats struct { @@ -30,3 +37,122 @@ var ( Once sync.Once Initialised bool = false ) + +type UserAuth struct { + Id int64 + UserId int64 + AuthModule string + AuthId string + Created time.Time + OAuthAccessToken string + OAuthRefreshToken string + OAuthIdToken string + OAuthTokenType string + OAuthExpiry time.Time +} + +type ExternalUserInfo struct { + OAuthToken *oauth2.Token + AuthModule string + AuthId string + UserId int64 + Email string + Login string + Name string + Groups []string + OrgRoles map[int64]org.RoleType + IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync) + IsDisabled bool + SkipTeamSync bool +} + +func (e *ExternalUserInfo) String() string { + return fmt.Sprintf("%+v", *e) +} + +type LoginInfo struct { + AuthModule string + User *user.User + ExternalUser ExternalUserInfo + LoginUsername string + HTTPStatus int + Error error +} + +// RequestURIKey is used as key to save request URI in contexts +// (used for the Enterprise auditing feature) +type RequestURIKey struct{} + +// --------------------- +// COMMANDS + +type UpsertUserCommand struct { + ReqContext *contextmodel.ReqContext + ExternalUser *ExternalUserInfo + UserLookupParams + SignupAllowed bool + + Result *user.User +} + +type SetAuthInfoCommand struct { + AuthModule string + AuthId string + UserId int64 + OAuthToken *oauth2.Token +} + +type UpdateAuthInfoCommand struct { + AuthModule string + AuthId string + UserId int64 + OAuthToken *oauth2.Token +} + +type DeleteAuthInfoCommand struct { + UserAuth *UserAuth +} + +// ---------------------- +// QUERIES + +type LoginUserQuery struct { + ReqContext *contextmodel.ReqContext + Username string + Password string + User *user.User + IpAddress string + AuthModule string + Cfg *setting.Cfg +} + +type GetUserByAuthInfoQuery struct { + AuthModule string + AuthId string + UserLookupParams +} + +type UserLookupParams struct { + // Describes lookup order as well + UserID *int64 // if set, will try to find the user by id + Email *string // if set, will try to find the user by email + Login *string // if set, will try to find the user by login +} + +type GetExternalUserInfoByLoginQuery struct { + LoginOrEmail string + + Result *ExternalUserInfo +} + +type GetAuthInfoQuery struct { + UserId int64 + AuthModule string + AuthId string + + Result *UserAuth +} + +type GetUserLabelsQuery struct { + UserIDs []int64 +} diff --git a/pkg/services/login/userprotection.go b/pkg/services/login/userprotection.go index f77b6401244..4f94d6a030b 100644 --- a/pkg/services/login/userprotection.go +++ b/pkg/services/login/userprotection.go @@ -3,7 +3,6 @@ package login import ( "context" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/user" ) @@ -12,13 +11,13 @@ type UserProtectionService interface { } type Store interface { - GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error - GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error - GetUserLabels(ctx context.Context, query models.GetUserLabelsQuery) (map[int64]string, error) - SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error - UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error - UpdateAuthInfoDate(ctx context.Context, authInfo *models.UserAuth) error - DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAuthInfoCommand) error + GetExternalUserInfoByLogin(ctx context.Context, query *GetExternalUserInfoByLoginQuery) error + GetAuthInfo(ctx context.Context, query *GetAuthInfoQuery) error + GetUserLabels(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) + SetAuthInfo(ctx context.Context, cmd *SetAuthInfoCommand) error + UpdateAuthInfo(ctx context.Context, cmd *UpdateAuthInfoCommand) error + UpdateAuthInfoDate(ctx context.Context, authInfo *UserAuth) error + DeleteAuthInfo(ctx context.Context, cmd *DeleteAuthInfoCommand) error GetUserById(ctx context.Context, id int64) (*user.User, error) GetUserByLogin(ctx context.Context, login string) (*user.User, error) GetUserByEmail(ctx context.Context, email string) (*user.User, error) diff --git a/pkg/services/multildap/multidap_mock.go b/pkg/services/multildap/multidap_mock.go index d59ca876f00..31b2557d0a3 100644 --- a/pkg/services/multildap/multidap_mock.go +++ b/pkg/services/multildap/multidap_mock.go @@ -1,8 +1,8 @@ package multildap import ( - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" ) @@ -16,25 +16,25 @@ type MultiLDAPmock struct { ExpectedErr error } -func (m *MultiLDAPmock) Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, error, +func (m *MultiLDAPmock) Login(query *login.LoginUserQuery) ( + *login.ExternalUserInfo, error, ) { m.LoginCalled = true query.User = m.UserInfo query.AuthModule = m.AuthModule - result := &models.ExternalUserInfo{ + result := &login.ExternalUserInfo{ UserId: m.ID, } return result, m.ExpectedErr } -func (m *MultiLDAPmock) User(login string) ( - *models.ExternalUserInfo, +func (m *MultiLDAPmock) User(loginstr string) ( + *login.ExternalUserInfo, ldap.ServerConfig, error, ) { m.UserCalled = true - result := &models.ExternalUserInfo{ + result := &login.ExternalUserInfo{ UserId: m.ID, } return result, ldap.ServerConfig{}, nil diff --git a/pkg/services/multildap/multildap.go b/pkg/services/multildap/multildap.go index b9ea8bd9ef3..25ec86fde72 100644 --- a/pkg/services/multildap/multildap.go +++ b/pkg/services/multildap/multildap.go @@ -4,8 +4,8 @@ import ( "errors" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" ) // logger to log @@ -43,16 +43,16 @@ type ServerStatus struct { // IMultiLDAP is interface for MultiLDAP type IMultiLDAP interface { Ping() ([]*ServerStatus, error) - Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, error, + Login(query *login.LoginUserQuery) ( + *login.ExternalUserInfo, error, ) Users(logins []string) ( - []*models.ExternalUserInfo, error, + []*login.ExternalUserInfo, error, ) User(login string) ( - *models.ExternalUserInfo, ldap.ServerConfig, error, + *login.ExternalUserInfo, ldap.ServerConfig, error, ) } @@ -99,8 +99,8 @@ func (multiples *MultiLDAP) Ping() ([]*ServerStatus, error) { } // Login tries to log in the user in multiples LDAP -func (multiples *MultiLDAP) Login(query *models.LoginUserQuery) ( - *models.ExternalUserInfo, error, +func (multiples *MultiLDAP) Login(query *login.LoginUserQuery) ( + *login.ExternalUserInfo, error, ) { if len(multiples.configs) == 0 { return nil, ErrNoLDAPServers @@ -157,7 +157,7 @@ func (multiples *MultiLDAP) Login(query *models.LoginUserQuery) ( // User attempts to find an user by login/username by searching into all of the configured LDAP servers. Then, if the user is found it returns the user alongisde the server it was found. func (multiples *MultiLDAP) User(login string) ( - *models.ExternalUserInfo, + *login.ExternalUserInfo, ldap.ServerConfig, error, ) { @@ -200,10 +200,10 @@ func (multiples *MultiLDAP) User(login string) ( // Users gets users from multiple LDAP servers func (multiples *MultiLDAP) Users(logins []string) ( - []*models.ExternalUserInfo, + []*login.ExternalUserInfo, error, ) { - var result []*models.ExternalUserInfo + var result []*login.ExternalUserInfo if len(multiples.configs) == 0 { return nil, ErrNoLDAPServers diff --git a/pkg/services/multildap/multildap_test.go b/pkg/services/multildap/multildap_test.go index cbf33532b2d..8d61448cdde 100644 --- a/pkg/services/multildap/multildap_test.go +++ b/pkg/services/multildap/multildap_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" "github.com/stretchr/testify/require" @@ -71,7 +71,7 @@ func TestMultiLDAP(t *testing.T) { setup() multi := New([]*ldap.ServerConfig{}) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Error(t, err) require.Equal(t, ErrNoLDAPServers, err) @@ -89,7 +89,7 @@ func TestMultiLDAP(t *testing.T) { {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Error(t, err) require.Equal(t, expected, err) @@ -104,7 +104,7 @@ func TestMultiLDAP(t *testing.T) { multi := New([]*ldap.ServerConfig{ {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 2, mock.dialCalledTimes) require.Equal(t, 2, mock.loginCalledTimes) @@ -118,14 +118,14 @@ func TestMultiLDAP(t *testing.T) { t.Run("Should get login result", func(t *testing.T) { mock := setup() - mock.loginReturn = &models.ExternalUserInfo{ + mock.loginReturn = &login.ExternalUserInfo{ Login: "killa", } multi := New([]*ldap.ServerConfig{ {}, {}, }) - result, err := multi.Login(&models.LoginUserQuery{}) + result, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 1, mock.dialCalledTimes) require.Equal(t, 1, mock.loginCalledTimes) @@ -145,7 +145,7 @@ func TestMultiLDAP(t *testing.T) { multi := New([]*ldap.ServerConfig{ {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 2, mock.dialCalledTimes) require.Equal(t, 2, mock.loginCalledTimes) @@ -164,7 +164,7 @@ func TestMultiLDAP(t *testing.T) { multi := New([]*ldap.ServerConfig{ {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 2, mock.dialCalledTimes) require.Equal(t, 2, mock.loginCalledTimes) @@ -184,7 +184,7 @@ func TestMultiLDAP(t *testing.T) { multi := New([]*ldap.ServerConfig{ {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 2, mock.dialCalledTimes) @@ -202,7 +202,7 @@ func TestMultiLDAP(t *testing.T) { multi := New([]*ldap.ServerConfig{ {}, {}, }) - _, err := multi.Login(&models.LoginUserQuery{}) + _, err := multi.Login(&login.LoginUserQuery{}) require.Equal(t, 1, mock.dialCalledTimes) require.Equal(t, 1, mock.loginCalledTimes) @@ -285,7 +285,7 @@ func TestMultiLDAP(t *testing.T) { t.Run("Should get only one user", func(t *testing.T) { mock := setup() - mock.usersFirstReturn = []*models.ExternalUserInfo{ + mock.usersFirstReturn = []*login.ExternalUserInfo{ { Login: "one", }, @@ -415,7 +415,7 @@ func TestMultiLDAP(t *testing.T) { t.Run("Should get users", func(t *testing.T) { mock := setup() - mock.usersFirstReturn = []*models.ExternalUserInfo{ + mock.usersFirstReturn = []*login.ExternalUserInfo{ { Login: "one", }, @@ -425,7 +425,7 @@ func TestMultiLDAP(t *testing.T) { }, } - mock.usersRestReturn = []*models.ExternalUserInfo{ + mock.usersRestReturn = []*login.ExternalUserInfo{ { Login: "three", }, @@ -461,23 +461,23 @@ type mockLDAP struct { dialErrReturn error loginErrReturn error - loginReturn *models.ExternalUserInfo + loginReturn *login.ExternalUserInfo bindErrReturn error usersErrReturn error - usersFirstReturn []*models.ExternalUserInfo - usersRestReturn []*models.ExternalUserInfo + usersFirstReturn []*login.ExternalUserInfo + usersRestReturn []*login.ExternalUserInfo } // Login test fn -func (mock *mockLDAP) Login(*models.LoginUserQuery) (*models.ExternalUserInfo, error) { +func (mock *mockLDAP) Login(*login.LoginUserQuery) (*login.ExternalUserInfo, error) { mock.loginCalledTimes++ return mock.loginReturn, mock.loginErrReturn } // Users test fn -func (mock *mockLDAP) Users([]string) ([]*models.ExternalUserInfo, error) { +func (mock *mockLDAP) Users([]string) ([]*login.ExternalUserInfo, error) { mock.usersCalledTimes++ if mock.usersCalledTimes == 1 { diff --git a/pkg/services/navtree/navtree.go b/pkg/services/navtree/navtree.go index 37cbe63c27d..8b641347547 100644 --- a/pkg/services/navtree/navtree.go +++ b/pkg/services/navtree/navtree.go @@ -1,10 +1,10 @@ package navtree import ( - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" pref "github.com/grafana/grafana/pkg/services/preference" ) type Service interface { - GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*NavTreeRoot, error) + GetNavTree(c *contextmodel.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*NavTreeRoot, error) } diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 00c6cf09fde..9a565dc37d4 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -1,9 +1,9 @@ package navtreeimpl import ( - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/serviceaccounts" ) -func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, error) { +func (s *ServiceImpl) getOrgAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink, error) { var configNodes []*navtree.NavLink hasAccess := ac.HasAccess(s.accessControl, c) @@ -119,7 +119,7 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e return configNode, nil } -func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink { +func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c) orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead) @@ -204,11 +204,11 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink return adminNode } -func (s *ServiceImpl) ReqCanAdminTeams(c *models.ReqContext) bool { +func (s *ServiceImpl) ReqCanAdminTeams(c *contextmodel.ReqContext) bool { return c.OrgRole == org.RoleAdmin || (s.cfg.EditorsCanAdmin && c.OrgRole == org.RoleEditor) } -func enableServiceAccount(s *ServiceImpl, c *models.ReqContext) bool { +func enableServiceAccount(s *ServiceImpl, c *contextmodel.ReqContext) bool { hasAccess := ac.HasAccess(s.accessControl, c) return hasAccess(ac.ReqOrgAdmin, serviceaccounts.AccessEvaluator) } diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index dddffbcb5d0..d2bccdb4c56 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -5,16 +5,16 @@ import ( "sort" "strconv" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/util" ) -func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) error { +func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) error { topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav) hasAccess := ac.HasAccess(s.accessControl, c) appLinks := []*navtree.NavLink{} @@ -64,7 +64,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqCo return nil } -func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink { +func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink { hasAccessToInclude := s.hasAccessToInclude(c, plugin.ID) appLink := &navtree.NavLink{ Text: plugin.Name, @@ -176,7 +176,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo return nil } -func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree.NavTreeRoot, plugin plugins.PluginDTO, appLink *navtree.NavLink) { +func (s *ServiceImpl) addPluginToSection(c *contextmodel.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 @@ -241,7 +241,7 @@ func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree } } -func (s *ServiceImpl) hasAccessToInclude(c *models.ReqContext, pluginID string) func(include *plugins.Includes) bool { +func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID string) func(include *plugins.Includes) bool { hasAccess := ac.HasAccess(s.accessControl, c) return func(include *plugins.Includes) bool { useRBAC := s.features.IsEnabled(featuremgmt.FlagAccessControlOnCall) && diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index 79d39b287ca..2864a5fa9c8 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" @@ -23,7 +23,7 @@ import ( func TestAddAppLinks(t *testing.T) { httpReq, _ := http.NewRequest(http.MethodGet, "", nil) - reqCtx := &models.ReqContext{SignedInUser: &user.SignedInUser{}, Context: &web.Context{Req: httpReq}} + reqCtx := &contextmodel.ReqContext{SignedInUser: &user.SignedInUser{}, Context: &web.Context{Req: httpReq}} permissions := []ac.Permission{ {Action: plugins.ActionAppAccess, Scope: "*"}, {Action: plugins.ActionInstall, Scope: "*"}, @@ -388,7 +388,7 @@ func TestReadingNavigationSettings(t *testing.T) { func TestAddAppLinksAccessControl(t *testing.T) { httpReq, _ := http.NewRequest(http.MethodGet, "", nil) user := &user.SignedInUser{OrgID: 1} - reqCtx := &models.ReqContext{SignedInUser: user, Context: &web.Context{Req: httpReq}} + reqCtx := &contextmodel.ReqContext{SignedInUser: user, Context: &web.Context{Req: httpReq}} catalogReadAction := "test-app1.catalog:read" testApp1 := plugins.PluginDTO{ diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index a5a1215e7f7..57c6097614c 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -7,10 +7,10 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -71,7 +71,7 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor } //nolint:gocyclo -func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*navtree.NavTreeRoot, error) { +func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*navtree.NavTreeRoot, error) { hasAccess := ac.HasAccess(s.accessControl, c) treeRoot := &navtree.NavTreeRoot{} @@ -111,7 +111,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs * treeRoot.AddSection(dashboardLink) } - canExplore := func(context *models.ReqContext) bool { + canExplore := func(context *contextmodel.ReqContext) bool { return c.OrgRole == org.RoleAdmin || c.OrgRole == org.RoleEditor || setting.ViewersCanEdit } @@ -210,7 +210,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs * return treeRoot, nil } -func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference) *navtree.NavLink { +func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Preference) *navtree.NavLink { homeUrl := s.cfg.AppSubURL + "/" homePage := s.cfg.HomePage @@ -232,7 +232,7 @@ func (s *ServiceImpl) getHomeNode(c *models.ReqContext, prefs *pref.Preference) return homeNode } -func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) { +func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) { if setting.HelpEnabled { helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit) if s.cfg.AnonymousHideVersion && !c.IsSignedIn { @@ -261,7 +261,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC } } -func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink { +func (s *ServiceImpl) getProfileNode(c *contextmodel.ReqContext) *navtree.NavLink { // Only set login if it's different from the name var login string if c.SignedInUser.Login != c.SignedInUser.NameOrFallback() { @@ -311,7 +311,7 @@ func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink { } } -func (s *ServiceImpl) buildStarredItemsNavLinks(c *models.ReqContext) ([]*navtree.NavLink, error) { +func (s *ServiceImpl) buildStarredItemsNavLinks(c *contextmodel.ReqContext) ([]*navtree.NavLink, error) { starredItemsChildNavs := []*navtree.NavLink{} query := star.GetUserStarsQuery{ @@ -335,9 +335,9 @@ func (s *ServiceImpl) buildStarredItemsNavLinks(c *models.ReqContext) ([]*navtre ID: dashboardId, OrgID: c.OrgID, } - err := s.dashboardService.GetDashboard(c.Req.Context(), query) + queryResult, err := s.dashboardService.GetDashboard(c.Req.Context(), query) if err == nil { - starredDashboards = append(starredDashboards, query.Result) + starredDashboards = append(starredDashboards, queryResult) } } @@ -357,9 +357,9 @@ func (s *ServiceImpl) buildStarredItemsNavLinks(c *models.ReqContext) ([]*navtre return starredItemsChildNavs, nil } -func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink { +func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext, hasEditPerm bool) []*navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) - hasEditPermInAnyFolder := func(c *models.ReqContext) bool { + hasEditPermInAnyFolder := func(c *contextmodel.ReqContext) bool { return hasEditPerm } @@ -376,13 +376,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) if c.IsSignedIn { - dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "Snapshots", - SubTitle: "Interactive, publically available, point-in-time representations of dashboards", - Id: "dashboards/snapshots", - Url: s.cfg.AppSubURL + "/dashboard/snapshots", - Icon: "camera", - }) + if s.cfg.SnapshotEnabled { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Snapshots", + SubTitle: "Interactive, publically available, point-in-time representations of dashboards", + Id: "dashboards/snapshots", + Url: s.cfg.AppSubURL + "/dashboard/snapshots", + Icon: "camera", + }) + } dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Library panels", @@ -444,7 +446,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b return dashboardChildNavs } -func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.NavLink { +func (s *ServiceImpl) buildLegacyAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink { var alertChildNavs []*navtree.NavLink alertChildNavs = append(alertChildNavs, &navtree.NavLink{ Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", @@ -476,7 +478,7 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.Na return &alertNav } -func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) *navtree.NavLink { +func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext, hasEditPerm bool) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) var alertChildNavs []*navtree.NavLink @@ -515,7 +517,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) }) } - fallbackHasEditPerm := func(*models.ReqContext) bool { return hasEditPerm } + fallbackHasEditPerm := func(*contextmodel.ReqContext) bool { return hasEditPerm } if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { if !s.features.IsEnabled(featuremgmt.FlagTopnav) { @@ -553,7 +555,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) return nil } -func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree.NavLink { +func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) var children []*navtree.NavLink diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 7b801795eeb..0bcf6efa641 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -14,8 +14,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -42,7 +42,7 @@ func (e UnknownReceiverError) Error() string { return fmt.Sprintf("unknown receiver: %s", e.UID) } -func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetAMStatus(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -51,7 +51,7 @@ func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, am.GetStatus()) } -func (srv AlertmanagerSrv) RouteCreateSilence(c *models.ReqContext, postableSilence apimodels.PostableSilence) response.Response { +func (srv AlertmanagerSrv) RouteCreateSilence(c *contextmodel.ReqContext, postableSilence apimodels.PostableSilence) response.Response { err := postableSilence.Validate(strfmt.Default) if err != nil { srv.log.Error("silence failed validation", "error", err) @@ -92,7 +92,7 @@ func (srv AlertmanagerSrv) RouteCreateSilence(c *models.ReqContext, postableSile }) } -func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -106,7 +106,7 @@ func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *models.ReqContext) respo return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration deleted; the default is applied"}) } -func (srv AlertmanagerSrv) RouteDeleteSilence(c *models.ReqContext, silenceID string) response.Response { +func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenceID string) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -121,7 +121,7 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *models.ReqContext, silenceID st return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"}) } -func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *contextmodel.ReqContext) response.Response { config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgID) if err != nil { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { @@ -132,7 +132,7 @@ func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *models.ReqContext) response return response.JSON(http.StatusOK, config) } -func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -156,7 +156,7 @@ func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *models.ReqContext) response. return response.JSON(http.StatusOK, groups) } -func (srv AlertmanagerSrv) RouteGetAMAlerts(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetAMAlerts(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -183,7 +183,7 @@ func (srv AlertmanagerSrv) RouteGetAMAlerts(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, alerts) } -func (srv AlertmanagerSrv) RouteGetSilence(c *models.ReqContext, silenceID string) response.Response { +func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID string) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -200,7 +200,7 @@ func (srv AlertmanagerSrv) RouteGetSilence(c *models.ReqContext, silenceID strin return response.JSON(http.StatusOK, gettableSilence) } -func (srv AlertmanagerSrv) RouteGetSilences(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -217,7 +217,7 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, gettableSilences) } -func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body apimodels.PostableUserConfig) response.Response { +func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, body apimodels.PostableUserConfig) response.Response { currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgID) // If a config is present and valid we proceed with the guard, otherwise we // just bypass the guard which is okay as we are anyway in an invalid state. @@ -248,7 +248,7 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap return ErrResp(http.StatusInternalServerError, err, "") } -func (srv AlertmanagerSrv) RouteGetReceivers(c *models.ReqContext) response.Response { +func (srv AlertmanagerSrv) RouteGetReceivers(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { return errResp @@ -258,7 +258,7 @@ func (srv AlertmanagerSrv) RouteGetReceivers(c *models.ReqContext) response.Resp return response.JSON(http.StatusOK, rcvs) } -func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response { +func (srv AlertmanagerSrv) RoutePostTestReceivers(c *contextmodel.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response { if err := srv.crypto.LoadSecureSettings(c.Req.Context(), c.OrgID, body.Receivers); err != nil { var unknownReceiverError UnknownReceiverError if errors.As(err, &unknownReceiverError) { diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 0553775fc66..ce9cb47d4b2 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -16,9 +16,9 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -166,7 +166,7 @@ func TestAlertmanagerConfig(t *testing.T) { sut := createSut(t, nil) t.Run("assert 404 Not Found when applying config to nonexistent org", func(t *testing.T) { - rc := models.ReqContext{ + rc := contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -183,7 +183,7 @@ func TestAlertmanagerConfig(t *testing.T) { }) t.Run("assert 202 when config successfully applied", func(t *testing.T) { - rc := models.ReqContext{ + rc := contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -200,7 +200,7 @@ func TestAlertmanagerConfig(t *testing.T) { t.Run("assert 202 when alertmanager to configure is not ready", func(t *testing.T) { sut := createSut(t, nil) - rc := models.ReqContext{ + rc := contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -330,7 +330,7 @@ func TestSilenceCreate(t *testing.T) { for _, cas := range cases { t.Run(cas.name, func(t *testing.T) { - rc := models.ReqContext{ + rc := contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -456,7 +456,7 @@ func TestRouteCreateSilence(t *testing.T) { ac := tesCase.accessControl() sut := createSut(t, ac) - rc := models.ReqContext{ + rc := contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -622,8 +622,8 @@ func withEmptyID(silence *apimodels.PostableSilence) { silence.ID = "" } -func createRequestCtxInOrg(org int64) *models.ReqContext { - return &models.ReqContext{ +func createRequestCtxInOrg(org int64) *contextmodel.ReqContext { + return &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, diff --git a/pkg/services/ngalert/api/api_configuration.go b/pkg/services/ngalert/api/api_configuration.go index 4d78029bd64..9ffb08c4ff5 100644 --- a/pkg/services/ngalert/api/api_configuration.go +++ b/pkg/services/ngalert/api/api_configuration.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -26,7 +26,7 @@ type ConfigSrv struct { log log.Logger } -func (srv ConfigSrv) RouteGetAlertmanagers(c *models.ReqContext) response.Response { +func (srv ConfigSrv) RouteGetAlertmanagers(c *contextmodel.ReqContext) response.Response { urls := srv.alertmanagerProvider.AlertmanagersFor(c.OrgID) droppedURLs := srv.alertmanagerProvider.DroppedAlertmanagersFor(c.OrgID) ams := v1.AlertManagersResult{Active: make([]v1.AlertManager, len(urls)), Dropped: make([]v1.AlertManager, len(droppedURLs))} @@ -43,7 +43,7 @@ func (srv ConfigSrv) RouteGetAlertmanagers(c *models.ReqContext) response.Respon }) } -func (srv ConfigSrv) RouteGetNGalertConfig(c *models.ReqContext) response.Response { +func (srv ConfigSrv) RouteGetNGalertConfig(c *contextmodel.ReqContext) response.Response { if c.OrgRole != org.RoleAdmin { return accessForbiddenResp() } @@ -65,7 +65,7 @@ func (srv ConfigSrv) RouteGetNGalertConfig(c *models.ReqContext) response.Respon return response.JSON(http.StatusOK, resp) } -func (srv ConfigSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels.PostableNGalertConfig) response.Response { +func (srv ConfigSrv) RoutePostNGalertConfig(c *contextmodel.ReqContext, body apimodels.PostableNGalertConfig) response.Response { if c.OrgRole != org.RoleAdmin { return accessForbiddenResp() } @@ -99,7 +99,7 @@ func (srv ConfigSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels return response.JSON(http.StatusCreated, util.DynMap{"message": "admin configuration updated"}) } -func (srv ConfigSrv) RouteDeleteNGalertConfig(c *models.ReqContext) response.Response { +func (srv ConfigSrv) RouteDeleteNGalertConfig(c *contextmodel.ReqContext) response.Response { if c.OrgRole != org.RoleAdmin { return accessForbiddenResp() } @@ -135,7 +135,7 @@ func (srv ConfigSrv) externalAlertmanagers(ctx context.Context, orgID int64) ([] return alertmanagers, nil } -func (srv ConfigSrv) RouteGetAlertingStatus(c *models.ReqContext) response.Response { +func (srv ConfigSrv) RouteGetAlertingStatus(c *contextmodel.ReqContext) response.Response { sendsAlertsTo := ngmodels.InternalAlertmanager cfg, err := srv.store.GetAdminConfiguration(c.OrgID) diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index c894258bf20..7c1a682e9fe 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -32,7 +32,7 @@ type PrometheusSrv struct { const queryIncludeInternalLabels = "includeInternalLabels" -func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Response { +func (srv PrometheusSrv) RouteGetAlertStatuses(c *contextmodel.ReqContext) response.Response { alertResponse := apimodels.AlertResponse{ DiscoveryBase: apimodels.DiscoveryBase{ Status: "success", @@ -105,7 +105,7 @@ func getPanelIDFromRequest(r *http.Request) (int64, error) { return 0, nil } -func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Response { +func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) response.Response { dashboardUID := c.Query("dashboard_uid") panelID, err := getPanelIDFromRequest(c.Req) if err != nil { diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 4bf06b8b4a4..0014b331aa1 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -15,8 +15,8 @@ import ( alertingModels "github.com/grafana/alerting/alerting/models" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -93,7 +93,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { _, _, _, api := setupAPI(t) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} r := api.RouteGetAlertStatuses(c) require.Equal(t, http.StatusOK, r.Status()) @@ -112,7 +112,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} r := api.RouteGetAlertStatuses(c) require.Equal(t, http.StatusOK, r.Status()) @@ -154,7 +154,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2, withAlertingState()) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} r := api.RouteGetAlertStatuses(c) require.Equal(t, http.StatusOK, r.Status()) @@ -196,7 +196,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { fakeAIM.GenerateAlertInstances(orgID, util.GenerateShortUID(), 2) req, err := http.NewRequest("GET", "/api/v1/alerts?includeInternalLabels=true", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID}} r := api.RouteGetAlertStatuses(c) require.Equal(t, http.StatusOK, r.Status()) @@ -258,7 +258,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { req, err := http.NewRequest("GET", "/api/v1/rules", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer}} t.Run("with no rules", func(t *testing.T) { _, _, _, api := setupAPI(t) @@ -328,7 +328,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { req, err := http.NewRequest("GET", "/api/v1/rules?includeInternalLabels=true", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer}} + c := &contextmodel.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer}} r := api.RouteGetRuleStatuses(c) require.Equal(t, http.StatusOK, r.Status()) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 748b78106aa..93c3e3696bf 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -3,15 +3,18 @@ package api import ( "context" "errors" + "fmt" "net/http" + "strings" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/provisioning/alerting/file" "github.com/grafana/grafana/pkg/util" ) @@ -60,9 +63,12 @@ type AlertRuleService interface { DeleteAlertRule(ctx context.Context, orgID int64, ruleUID string, provenance alerting_models.Provenance) error GetRuleGroup(ctx context.Context, orgID int64, folder, group string) (alerting_models.AlertRuleGroup, error) ReplaceRuleGroup(ctx context.Context, orgID int64, group alerting_models.AlertRuleGroup, userID int64, provenance alerting_models.Provenance) error + GetAlertRuleWithFolderTitle(ctx context.Context, orgID int64, ruleUID string) (provisioning.AlertRuleWithFolderTitle, error) + GetAlertRuleGroupWithFolderTitle(ctx context.Context, orgID int64, folder, group string) (file.AlertRuleGroupWithFolderTitle, error) + GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64) ([]file.AlertRuleGroupWithFolderTitle, error) } -func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteGetPolicyTree(c *contextmodel.ReqContext) response.Response { policies, err := srv.policies.GetPolicyTree(c.Req.Context(), c.OrgID) if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") @@ -74,7 +80,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re return response.JSON(http.StatusOK, policies) } -func (srv *ProvisioningSrv) RoutePutPolicyTree(c *models.ReqContext, tree definitions.Route) response.Response { +func (srv *ProvisioningSrv) RoutePutPolicyTree(c *contextmodel.ReqContext, tree definitions.Route) response.Response { err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgID, tree, alerting_models.ProvenanceAPI) if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") @@ -89,7 +95,7 @@ func (srv *ProvisioningSrv) RoutePutPolicyTree(c *models.ReqContext, tree defini return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"}) } -func (srv *ProvisioningSrv) RouteResetPolicyTree(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteResetPolicyTree(c *contextmodel.ReqContext) response.Response { tree, err := srv.policies.ResetPolicyTree(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -97,7 +103,7 @@ func (srv *ProvisioningSrv) RouteResetPolicyTree(c *models.ReqContext) response. return response.JSON(http.StatusAccepted, tree) } -func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteGetContactPoints(c *contextmodel.ReqContext) response.Response { q := provisioning.ContactPointQuery{ Name: c.Query("name"), OrgID: c.OrgID, @@ -109,7 +115,7 @@ func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response return response.JSON(http.StatusOK, cps) } -func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp definitions.EmbeddedContactPoint) response.Response { +func (srv *ProvisioningSrv) RoutePostContactPoint(c *contextmodel.ReqContext, cp definitions.EmbeddedContactPoint) response.Response { // TODO: provenance is hardcoded for now, change it later to make it more flexible contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgID, cp, alerting_models.ProvenanceAPI) if errors.Is(err, provisioning.ErrValidation) { @@ -121,7 +127,7 @@ func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp defin return response.JSON(http.StatusAccepted, contactPoint) } -func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp definitions.EmbeddedContactPoint, UID string) response.Response { +func (srv *ProvisioningSrv) RoutePutContactPoint(c *contextmodel.ReqContext, cp definitions.EmbeddedContactPoint, UID string) response.Response { cp.UID = UID err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgID, cp, alerting_models.ProvenanceAPI) if errors.Is(err, provisioning.ErrValidation) { @@ -136,7 +142,7 @@ func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp defini return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint updated"}) } -func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext, UID string) response.Response { +func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *contextmodel.ReqContext, UID string) response.Response { err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgID, UID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -144,7 +150,7 @@ func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext, UID st return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"}) } -func (srv *ProvisioningSrv) RouteGetTemplates(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteGetTemplates(c *contextmodel.ReqContext) response.Response { templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -156,7 +162,7 @@ func (srv *ProvisioningSrv) RouteGetTemplates(c *models.ReqContext) response.Res return response.JSON(http.StatusOK, result) } -func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext, name string) response.Response { +func (srv *ProvisioningSrv) RouteGetTemplate(c *contextmodel.ReqContext, name string) response.Response { templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -167,7 +173,7 @@ func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext, name string) return response.Empty(http.StatusNotFound) } -func (srv *ProvisioningSrv) RoutePutTemplate(c *models.ReqContext, body definitions.NotificationTemplateContent, name string) response.Response { +func (srv *ProvisioningSrv) RoutePutTemplate(c *contextmodel.ReqContext, body definitions.NotificationTemplateContent, name string) response.Response { tmpl := definitions.NotificationTemplate{ Name: name, Template: body.Template, @@ -183,7 +189,7 @@ func (srv *ProvisioningSrv) RoutePutTemplate(c *models.ReqContext, body definiti return response.JSON(http.StatusAccepted, modified) } -func (srv *ProvisioningSrv) RouteDeleteTemplate(c *models.ReqContext, name string) response.Response { +func (srv *ProvisioningSrv) RouteDeleteTemplate(c *contextmodel.ReqContext, name string) response.Response { err := srv.templates.DeleteTemplate(c.Req.Context(), c.OrgID, name) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -191,7 +197,7 @@ func (srv *ProvisioningSrv) RouteDeleteTemplate(c *models.ReqContext, name strin return response.JSON(http.StatusNoContent, nil) } -func (srv *ProvisioningSrv) RouteGetMuteTiming(c *models.ReqContext, name string) response.Response { +func (srv *ProvisioningSrv) RouteGetMuteTiming(c *contextmodel.ReqContext, name string) response.Response { timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -204,7 +210,7 @@ func (srv *ProvisioningSrv) RouteGetMuteTiming(c *models.ReqContext, name string return response.Empty(http.StatusNotFound) } -func (srv *ProvisioningSrv) RouteGetMuteTimings(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteGetMuteTimings(c *contextmodel.ReqContext) response.Response { timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -212,7 +218,7 @@ func (srv *ProvisioningSrv) RouteGetMuteTimings(c *models.ReqContext) response.R return response.JSON(http.StatusOK, timings) } -func (srv *ProvisioningSrv) RoutePostMuteTiming(c *models.ReqContext, mt definitions.MuteTimeInterval) response.Response { +func (srv *ProvisioningSrv) RoutePostMuteTiming(c *contextmodel.ReqContext, mt definitions.MuteTimeInterval) response.Response { mt.Provenance = alerting_models.ProvenanceAPI created, err := srv.muteTimings.CreateMuteTiming(c.Req.Context(), mt, c.OrgID) if err != nil { @@ -224,7 +230,7 @@ func (srv *ProvisioningSrv) RoutePostMuteTiming(c *models.ReqContext, mt definit return response.JSON(http.StatusCreated, created) } -func (srv *ProvisioningSrv) RoutePutMuteTiming(c *models.ReqContext, mt definitions.MuteTimeInterval, name string) response.Response { +func (srv *ProvisioningSrv) RoutePutMuteTiming(c *contextmodel.ReqContext, mt definitions.MuteTimeInterval, name string) response.Response { mt.Name = name mt.Provenance = alerting_models.ProvenanceAPI updated, err := srv.muteTimings.UpdateMuteTiming(c.Req.Context(), mt, c.OrgID) @@ -240,7 +246,7 @@ func (srv *ProvisioningSrv) RoutePutMuteTiming(c *models.ReqContext, mt definiti return response.JSON(http.StatusAccepted, updated) } -func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *models.ReqContext, name string) response.Response { +func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *contextmodel.ReqContext, name string) response.Response { err := srv.muteTimings.DeleteMuteTiming(c.Req.Context(), name, c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -248,7 +254,7 @@ func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *models.ReqContext, name str return response.JSON(http.StatusNoContent, nil) } -func (srv *ProvisioningSrv) RouteGetAlertRules(c *models.ReqContext) response.Response { +func (srv *ProvisioningSrv) RouteGetAlertRules(c *contextmodel.ReqContext) response.Response { rules, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.OrgID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -256,7 +262,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRules(c *models.ReqContext) response.Re return response.JSON(http.StatusOK, definitions.NewAlertRules(rules)) } -func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *models.ReqContext, UID string) response.Response { +func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *contextmodel.ReqContext, UID string) response.Response { rule, provenace, err := srv.alertRules.GetAlertRule(c.Req.Context(), c.OrgID, UID) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -264,7 +270,7 @@ func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *models.ReqContext, UID str return response.JSON(http.StatusOK, definitions.NewAlertRule(rule, provenace)) } -func (srv *ProvisioningSrv) RoutePostAlertRule(c *models.ReqContext, ar definitions.ProvisionedAlertRule) response.Response { +func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar definitions.ProvisionedAlertRule) response.Response { upstreamModel, err := ar.UpstreamModel() upstreamModel.OrgID = c.OrgID if err != nil { @@ -289,7 +295,7 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *models.ReqContext, ar definiti return response.JSON(http.StatusCreated, resp) } -func (srv *ProvisioningSrv) RoutePutAlertRule(c *models.ReqContext, ar definitions.ProvisionedAlertRule, UID string) response.Response { +func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar definitions.ProvisionedAlertRule, UID string) response.Response { updated, err := ar.UpstreamModel() if err != nil { ErrResp(http.StatusBadRequest, err, "") @@ -315,7 +321,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *models.ReqContext, ar definitio return response.JSON(http.StatusOK, resp) } -func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *models.ReqContext, UID string) response.Response { +func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *contextmodel.ReqContext, UID string) response.Response { err := srv.alertRules.DeleteAlertRule(c.Req.Context(), c.OrgID, UID, alerting_models.ProvenanceAPI) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -323,7 +329,7 @@ func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *models.ReqContext, UID strin return response.JSON(http.StatusNoContent, "") } -func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *models.ReqContext, folder string, group string) response.Response { +func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *contextmodel.ReqContext, folder string, group string) response.Response { g, err := srv.alertRules.GetRuleGroup(c.Req.Context(), c.OrgID, folder, group) if err != nil { if errors.Is(err, store.ErrAlertRuleGroupNotFound) { @@ -334,7 +340,67 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *models.ReqContext, folder return response.JSON(http.StatusOK, definitions.NewAlertRuleGroupFromModel(g)) } -func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *models.ReqContext, ag definitions.AlertRuleGroup, folderUID string, group string) response.Response { +// RouteGetAlertRulesExport retrieves all alert rules in a format compatible with file provisioning. +func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext) response.Response { + groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.OrgID) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to get alert rules") + } + + e, err := file.NewAlertingFileExport(groupsWithTitle) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export") + } + + return exportResponse(c, e) +} + +// RouteGetAlertRuleGroupExport retrieves the given alert rule group in a format compatible with file provisioning. +func (srv *ProvisioningSrv) RouteGetAlertRuleGroupExport(c *contextmodel.ReqContext, folder string, group string) response.Response { + g, err := srv.alertRules.GetAlertRuleGroupWithFolderTitle(c.Req.Context(), c.OrgID, folder, group) + if err != nil { + if errors.Is(err, store.ErrAlertRuleGroupNotFound) { + return ErrResp(http.StatusNotFound, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "failed to get alert rule group") + } + + e, err := file.NewAlertingFileExport([]file.AlertRuleGroupWithFolderTitle{g}) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export") + } + + return exportResponse(c, e) +} + +// RouteGetAlertRuleExport retrieves the given alert rule in a format compatible with file provisioning. +func (srv *ProvisioningSrv) RouteGetAlertRuleExport(c *contextmodel.ReqContext, UID string) response.Response { + rule, err := srv.alertRules.GetAlertRuleWithFolderTitle(c.Req.Context(), c.OrgID, UID) + if err != nil { + if errors.Is(err, alerting_models.ErrAlertRuleNotFound) { + return ErrResp(http.StatusNotFound, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + + e, err := file.NewAlertingFileExport([]file.AlertRuleGroupWithFolderTitle{{ + AlertRuleGroup: &alerting_models.AlertRuleGroup{ + Title: rule.AlertRule.RuleGroup, + FolderUID: rule.AlertRule.NamespaceUID, + Interval: rule.AlertRule.IntervalSeconds, + Rules: []alerting_models.AlertRule{rule.AlertRule}, + }, + OrgID: c.OrgID, + FolderTitle: rule.FolderTitle, + }}) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export") + } + + return exportResponse(c, e) +} + +func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, ag definitions.AlertRuleGroup, folderUID string, group string) response.Response { ag.FolderUID = folderUID ag.Title = group groupModel, err := ag.ToModel() @@ -354,9 +420,32 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *models.ReqContext, ag defi return response.JSON(http.StatusOK, ag) } -func determineProvenance(ctx *models.ReqContext) alerting_models.Provenance { +func determineProvenance(ctx *contextmodel.ReqContext) alerting_models.Provenance { if _, disabled := ctx.Req.Header[disableProvenanceHeaderName]; disabled { return alerting_models.ProvenanceNone } return alerting_models.ProvenanceAPI } + +func exportResponse(c *contextmodel.ReqContext, body any) response.Response { + format := "json" + acceptHeader := c.Req.Header.Get("Accept") + if strings.Contains(acceptHeader, "yaml") && !strings.Contains(acceptHeader, "json") { + format = "yaml" + } + + download := c.QueryBoolWithDefault("download", false) + if download { + r := response.JSONDownload + if format == "yaml" { + r = response.YAMLDownload + } + return r(http.StatusOK, body, fmt.Sprintf("export.%s", format)) + } + + r := response.JSON + if format == "yaml" { + r = response.YAML + } + return r(http.StatusOK, body) +} diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 240751e4c56..183bf0c8d91 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -5,18 +5,22 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" + "net/url" "testing" "time" prometheus "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/common/model" + "github.com/stretchr/testify/mock" "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" - gfcore "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" @@ -370,17 +374,394 @@ func TestProvisioningApi(t *testing.T) { }) }) }) + + t.Run("exports", func(t *testing.T) { + t.Run("alert rule group", func(t *testing.T) { + t.Run("are present, GET returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + + require.Equal(t, 200, response.Status()) + }) + + t.Run("are missing, GET returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "does not exist") + + require.Equal(t, 404, response.Status()) + }) + + t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json") + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json, application/yaml") + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Form.Set("download", "true") + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment") + }) + + t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Form.Set("download", "false") + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("json body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + insertRule(t, sut, createTestAlertRule("rule2", 1)) + + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"},{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"}]}]}` + + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + + t.Run("yaml body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + insertRule(t, sut, createTestAlertRule("rule2", 1)) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n" + + response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group") + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + }) + + t.Run("alert rule", func(t *testing.T) { + t.Run("are present, GET returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + + require.Equal(t, 200, response.Status()) + }) + + t.Run("are missing, GET returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + response := sut.RouteGetAlertRuleExport(&rc, "rule404") + + require.Equal(t, 404, response.Status()) + }) + + t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json") + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json, application/yaml") + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Form.Set("download", "true") + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment") + }) + + t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Form.Set("download", "false") + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("json body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"}]}]}` + + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + + t.Run("yaml body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule1", 1)) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n" + + response := sut.RouteGetAlertRuleExport(&rc, "rule1") + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + }) + + t.Run("all alert rules", func(t *testing.T) { + t.Run("are present, GET returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 200, response.Status()) + }) + + t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json") + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Header.Add("Accept", "application/json, application/yaml") + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Form.Set("download", "true") + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment") + }) + + t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + rc.Context.Req.Form.Set("download", "false") + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRulesExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("json body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"-100"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s"}]}]}` + + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + + t.Run("yaml body content is as expected", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + + rc.Context.Req.Header.Add("Accept", "application/yaml") + expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: groupa\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n - orgId: 1\n name: groupb\n folder: Folder Title\n interval: 1m\n rules:\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n - orgId: 1\n name: groupb\n folder: Folder Title2\n interval: 1m\n rules:\n - uid: rule3\n title: rule3\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: \"-100\"\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n" + + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + }) + }) } // testEnvironment binds together common dependencies for testing alerting APIs. type testEnvironment struct { - secrets secrets.Service - log log.Logger - store store.DBstore - configs provisioning.AMConfigStore - xact provisioning.TransactionManager - quotas provisioning.QuotaChecker - prov provisioning.ProvisioningStore + secrets secrets.Service + log log.Logger + store store.DBstore + dashboardService dashboards.DashboardService + configs provisioning.AMConfigStore + xact provisioning.TransactionManager + quotas provisioning.QuotaChecker + prov provisioning.ProvisioningStore } func createTestEnv(t *testing.T) testEnvironment { @@ -407,14 +788,29 @@ func createTestEnv(t *testing.T) testEnvironment { prov.EXPECT().SaveSucceeds() prov.EXPECT().GetReturns(models.ProvenanceNone) + dashboardService := dashboards.NewFakeDashboardService(t) + dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(&dashboards.Dashboard{ + UID: "folder-uid", + Title: "Folder Title", + }, nil).Maybe() + dashboardService.On("GetDashboards", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardsQuery")).Return([]*dashboards.Dashboard{{ + UID: "folder-uid", + Title: "Folder Title", + }, + { + UID: "folder-uid2", + Title: "Folder Title2", + }}, nil).Maybe() + return testEnvironment{ - secrets: secrets, - log: log, - configs: configs, - store: store, - xact: xact, - prov: prov, - quotas: quotas, + secrets: secrets, + log: log, + configs: configs, + store: store, + dashboardService: dashboardService, + xact: xact, + prov: prov, + quotas: quotas, } } @@ -434,14 +830,18 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, env.log), templates: provisioning.NewTemplateService(env.configs, env.prov, env.xact, env.log), muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log), - alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.quotas, env.xact, 60, 10, env.log), + alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.dashboardService, env.quotas, env.xact, 60, 10, env.log), } } -func createTestRequestCtx() gfcore.ReqContext { - return gfcore.ReqContext{ +func createTestRequestCtx() contextmodel.ReqContext { + return contextmodel.ReqContext{ Context: &web.Context{ - Req: &http.Request{}, + Req: &http.Request{ + Header: make(http.Header), + Form: make(url.Values), + }, + Resp: web.NewResponseWriter("GET", httptest.NewRecorder()), }, SignedInUser: &user.SignedInUser{ OrgID: 1, @@ -555,15 +955,23 @@ func createInvalidAlertRuleGroup() definitions.AlertRuleGroup { } } +func createTestAlertRuleWithFolderAndGroup(title string, orgID int64, folderUid string, group string) definitions.ProvisionedAlertRule { + rule := createTestAlertRule(title, orgID) + rule.FolderUID = folderUid + rule.RuleGroup = group + return rule +} + func createTestAlertRule(title string, orgID int64) definitions.ProvisionedAlertRule { return definitions.ProvisionedAlertRule{ + UID: title, OrgID: orgID, Title: title, Condition: "A", Data: []models.AlertQuery{ { RefID: "A", - Model: json.RawMessage("{}"), + Model: json.RawMessage(testModel), RelativeTimeRange: models.RelativeTimeRange{ From: models.Duration(60), To: models.Duration(0), @@ -600,6 +1008,42 @@ func deserializeRule(t *testing.T, data []byte) definitions.ProvisionedAlertRule return rule } +var testModel = ` +{ + "conditions": [ + { + "evaluator": { + "params": [ + 3 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A" + ] + }, + "reducer": { + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "-100" + }, + "expression": "1==0", + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A", + "type": "math" +} +` + var testConfig = ` { "template_files": { diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 5b195599241..732ec14dcdb 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" @@ -53,7 +53,7 @@ var ( // or, if non-empty, a specific group of rules in the namespace. // Returns http.StatusUnauthorized if user does not have access to any of the rules that match the filter. // Returns http.StatusBadRequest if all rules that match the filter and the user is authorized to delete are provisioned. -func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext, namespaceTitle string, group string) response.Response { +func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgID, c.SignedInUser, true) if err != nil { return toNamespaceErrorResponse(err) @@ -155,7 +155,7 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext, namespaceTitle s } // RouteGetNamespaceRulesConfig returns all rules in a specific folder that user has access to -func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext, namespaceTitle string) response.Response { +func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceTitle string) response.Response { namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgID, c.SignedInUser, false) if err != nil { return toNamespaceErrorResponse(err) @@ -197,7 +197,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext, namespace // RouteGetRulesGroupConfig returns rules that belong to a specific group in a specific namespace (folder). // If user does not have access to at least one of the rule in the group, returns status 401 Unauthorized -func (srv RulerSrv) RouteGetRulesGroupConfig(c *models.ReqContext, namespaceTitle string, ruleGroup string) response.Response { +func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceTitle string, ruleGroup string) response.Response { namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgID, c.SignedInUser, false) if err != nil { return toNamespaceErrorResponse(err) @@ -232,7 +232,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *models.ReqContext, namespaceTitl } // RouteGetRulesConfig returns all alert rules that are available to the current user -func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response { +func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Response { namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.OrgID, c.SignedInUser) if err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to get namespaces visible to the user") @@ -301,7 +301,7 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response return response.JSON(http.StatusOK, result) } -func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response { +func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response { namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgID, c.SignedInUser, true) if err != nil { return toNamespaceErrorResponse(err) @@ -325,7 +325,7 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf // updateAlertRulesInGroup calculates changes (rules to add,update,delete), verifies that the user is authorized to do the calculated changes and updates database. // All operations are performed in a single transaction -func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRule) response.Response { +func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRule) response.Response { var finalChanges *store.GroupDelta hasAccess := accesscontrol.HasAccess(srv.ac, c) err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { @@ -426,7 +426,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgID, UID: rule.Existing.UID, - }, rule.Existing.Version+1) + }, rule.Existing.Version+1, rule.New.IsPaused) } if len(finalChanges.Delete) > 0 { diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 9f7d07103a2..43ee46caa8e 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -14,9 +14,9 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" - models2 "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -657,7 +657,7 @@ func createService(ac *acMock.Mock, store *fakes.RuleStore, scheduler schedule.S } } -func createRequestContext(orgID int64, role org.RoleType, params map[string]string) *models2.ReqContext { +func createRequestContext(orgID int64, role org.RoleType, params map[string]string) *contextmodel.ReqContext { uri, _ := url.Parse("http://localhost") ctx := web.Context{Req: &http.Request{ URL: uri, @@ -666,7 +666,7 @@ func createRequestContext(orgID int64, role org.RoleType, params map[string]stri ctx.Req = web.SetURLParams(ctx.Req, params) } - return &models2.ReqContext{ + return &contextmodel.ReqContext{ IsSignedIn: true, SignedInUser: &user.SignedInUser{ OrgRole: role, diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 91a3d09a1dd..acc9e0d6614 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -35,7 +35,7 @@ type TestingApiSrv struct { featureManager featuremgmt.FeatureToggles } -func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response { +func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response { if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil { return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String())) } @@ -73,7 +73,7 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *models.ReqContext, body a }) } -func (srv TestingApiSrv) RouteTestRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response { +func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response { if body.Type() != apimodels.LoTexRulerBackend { return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.LoTexRulerBackend, body.Type().String())) } @@ -111,7 +111,7 @@ func (srv TestingApiSrv) RouteTestRuleConfig(c *models.ReqContext, body apimodel ) } -func (srv TestingApiSrv) RouteEvalQueries(c *models.ReqContext, cmd apimodels.EvalQueriesPayload) response.Response { +func (srv TestingApiSrv) RouteEvalQueries(c *contextmodel.ReqContext, cmd apimodels.EvalQueriesPayload) response.Response { if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: cmd.Data}, func(evaluator accesscontrol.Evaluator) bool { return accesscontrol.HasAccess(srv.accessControl, c)(accesscontrol.ReqSignedIn, evaluator) }) { @@ -145,7 +145,7 @@ func (srv TestingApiSrv) RouteEvalQueries(c *models.ReqContext, cmd apimodels.Ev return response.JSONStreaming(http.StatusOK, evalResults) } -func (srv TestingApiSrv) BacktestAlertRule(c *models.ReqContext, cmd apimodels.BacktestConfig) response.Response { +func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimodels.BacktestConfig) response.Response { if !srv.featureManager.IsEnabled(featuremgmt.FlagAlertingBacktesting) { return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") } diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go index e76f5ca7749..af0d5d03047 100644 --- a/pkg/services/ngalert/api/api_testing_test.go +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - models2 "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" fakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -24,7 +24,7 @@ import ( func TestRouteTestGrafanaRuleConfig(t *testing.T) { t.Run("when fine-grained access is enabled", func(t *testing.T) { - rc := &models2.ReqContext{ + rc := &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -95,7 +95,7 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { }) t.Run("when fine-grained access is disabled", func(t *testing.T) { - rc := &models2.ReqContext{ + rc := &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -152,7 +152,7 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { func TestRouteEvalQueries(t *testing.T) { t.Run("when fine-grained access is enabled", func(t *testing.T) { - rc := &models2.ReqContext{ + rc := &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, @@ -222,7 +222,7 @@ func TestRouteEvalQueries(t *testing.T) { }) t.Run("when fine-grained access is disabled", func(t *testing.T) { - rc := &models2.ReqContext{ + rc := &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 4dfc8a85ab3..0a6c2a4d005 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -201,7 +201,10 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodGet + "/api/v1/provisioning/mute-timings/{name}", http.MethodGet + "/api/v1/provisioning/alert-rules", http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}", - http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": + http.MethodGet + "/api/v1/provisioning/alert-rules/export", + http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}/export", + http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}", + http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": fallback = middleware.ReqOrgAdmin eval = ac.EvalPermission(ac.ActionAlertingProvisioningRead) // organization scope diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index b4da61566fd..2957979a26d 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 41) + require.Len(t, paths, 44) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/configuration.go b/pkg/services/ngalert/api/configuration.go index 7bc382c92ab..4998f81a960 100644 --- a/pkg/services/ngalert/api/configuration.go +++ b/pkg/services/ngalert/api/configuration.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -17,22 +17,22 @@ func NewConfiguration(grafana *ConfigSrv) *ConfigurationApiHandler { } } -func (f *ConfigurationApiHandler) handleRouteGetAlertmanagers(c *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) handleRouteGetAlertmanagers(c *contextmodel.ReqContext) response.Response { return f.grafana.RouteGetAlertmanagers(c) } -func (f *ConfigurationApiHandler) handleRouteGetNGalertConfig(c *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) handleRouteGetNGalertConfig(c *contextmodel.ReqContext) response.Response { return f.grafana.RouteGetNGalertConfig(c) } -func (f *ConfigurationApiHandler) handleRoutePostNGalertConfig(c *models.ReqContext, body apimodels.PostableNGalertConfig) response.Response { +func (f *ConfigurationApiHandler) handleRoutePostNGalertConfig(c *contextmodel.ReqContext, body apimodels.PostableNGalertConfig) response.Response { return f.grafana.RoutePostNGalertConfig(c, body) } -func (f *ConfigurationApiHandler) handleRouteDeleteNGalertConfig(c *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) handleRouteDeleteNGalertConfig(c *contextmodel.ReqContext) response.Response { return f.grafana.RouteDeleteNGalertConfig(c) } -func (f *ConfigurationApiHandler) handleRouteGetStatus(c *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) handleRouteGetStatus(c *contextmodel.ReqContext) response.Response { return f.grafana.RouteGetAlertingStatus(c) } diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index c6d2a43ef8b..a45f35c9a98 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -22,7 +22,7 @@ func NewForkingAM(datasourceCache datasources.CacheService, proxy *LotexAM, graf } } -func (f *AlertmanagerApiHandler) getService(ctx *models.ReqContext) (*LotexAM, error) { +func (f *AlertmanagerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexAM, error) { _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.AlertmanagerBackend) if err != nil { return nil, err @@ -30,7 +30,7 @@ func (f *AlertmanagerApiHandler) getService(ctx *models.ReqContext) (*LotexAM, e return f.AMSvc, nil } -func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -39,7 +39,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *models.ReqContext, return s.RouteGetAMStatus(ctx) } -func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *models.ReqContext, body apimodels.PostableSilence, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *contextmodel.ReqContext, body apimodels.PostableSilence, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -48,7 +48,7 @@ func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *models.ReqContext return s.RouteCreateSilence(ctx, body) } -func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -57,7 +57,7 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *models.Req return s.RouteDeleteAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *models.ReqContext, silenceID string, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -66,7 +66,7 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *models.ReqContext return s.RouteDeleteSilence(ctx, silenceID) } -func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -75,7 +75,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *models.ReqCon return s.RouteGetAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -84,7 +84,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *models.ReqCont return s.RouteGetAMAlertGroups(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -93,7 +93,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *models.ReqContext, return s.RouteGetAMAlerts(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *models.ReqContext, silenceID string, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -102,7 +102,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *models.ReqContext, s return s.RouteGetSilence(ctx, silenceID) } -func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *models.ReqContext, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *contextmodel.ReqContext, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -111,7 +111,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *models.ReqContext, return s.RouteGetSilences(ctx) } -func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *models.ReqContext, body apimodels.PostableUserConfig, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel.ReqContext, body apimodels.PostableUserConfig, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -122,7 +122,7 @@ func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *models.ReqCo return s.RoutePostAlertingConfig(ctx, body) } -func (f *AlertmanagerApiHandler) handleRoutePostAMAlerts(ctx *models.ReqContext, body apimodels.PostableAlerts, dsUID string) response.Response { +func (f *AlertmanagerApiHandler) handleRoutePostAMAlerts(ctx *contextmodel.ReqContext, body apimodels.PostableAlerts, dsUID string) response.Response { s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -131,53 +131,53 @@ func (f *AlertmanagerApiHandler) handleRoutePostAMAlerts(ctx *models.ReqContext, return s.RoutePostAMAlerts(ctx, body) } -func (f *AlertmanagerApiHandler) handleRouteDeleteGrafanaSilence(ctx *models.ReqContext, id string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteDeleteGrafanaSilence(ctx *contextmodel.ReqContext, id string) response.Response { return f.GrafanaSvc.RouteDeleteSilence(ctx, id) } -func (f *AlertmanagerApiHandler) handleRouteDeleteGrafanaAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteDeleteGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteDeleteAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) handleRouteCreateGrafanaSilence(ctx *models.ReqContext, body apimodels.PostableSilence) response.Response { +func (f *AlertmanagerApiHandler) handleRouteCreateGrafanaSilence(ctx *contextmodel.ReqContext, body apimodels.PostableSilence) response.Response { return f.GrafanaSvc.RouteCreateSilence(ctx, body) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMStatus(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMStatus(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetAMStatus(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMAlerts(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMAlerts(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetAMAlerts(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMAlertGroups(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAMAlertGroups(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetAMAlertGroups(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilence(ctx *models.ReqContext, id string) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilence(ctx *contextmodel.ReqContext, id string) response.Response { return f.GrafanaSvc.RouteGetSilence(ctx, id) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilences(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilences(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetSilences(ctx) } -func (f *AlertmanagerApiHandler) handleRoutePostGrafanaAlertingConfig(ctx *models.ReqContext, conf apimodels.PostableUserConfig) response.Response { +func (f *AlertmanagerApiHandler) handleRoutePostGrafanaAlertingConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableUserConfig) response.Response { if !conf.AlertmanagerConfig.ReceiverType().Can(apimodels.GrafanaReceiverType) { return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, conf.AlertmanagerConfig.ReceiverType().String())) } return f.GrafanaSvc.RoutePostAlertingConfig(ctx, conf) } -func (f *AlertmanagerApiHandler) handleRouteGetGrafanaReceivers(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetReceivers(ctx) } -func (f *AlertmanagerApiHandler) handleRoutePostTestGrafanaReceivers(ctx *models.ReqContext, conf apimodels.TestReceiversConfigBodyParams) response.Response { +func (f *AlertmanagerApiHandler) handleRoutePostTestGrafanaReceivers(ctx *contextmodel.ReqContext, conf apimodels.TestReceiversConfigBodyParams) response.Response { return f.GrafanaSvc.RoutePostTestReceivers(ctx, conf) } diff --git a/pkg/services/ngalert/api/forking_prometheus.go b/pkg/services/ngalert/api/forking_prometheus.go index 492983e6a7e..c0fa78b9f74 100644 --- a/pkg/services/ngalert/api/forking_prometheus.go +++ b/pkg/services/ngalert/api/forking_prometheus.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -22,7 +22,7 @@ func NewForkingProm(datasourceCache datasources.CacheService, proxy *LotexProm, } } -func (f *PrometheusApiHandler) handleRouteGetAlertStatuses(ctx *models.ReqContext, dsUID string) response.Response { +func (f *PrometheusApiHandler) handleRouteGetAlertStatuses(ctx *contextmodel.ReqContext, dsUID string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -30,7 +30,7 @@ func (f *PrometheusApiHandler) handleRouteGetAlertStatuses(ctx *models.ReqContex return t.RouteGetAlertStatuses(ctx) } -func (f *PrometheusApiHandler) handleRouteGetRuleStatuses(ctx *models.ReqContext, dsUID string) response.Response { +func (f *PrometheusApiHandler) handleRouteGetRuleStatuses(ctx *contextmodel.ReqContext, dsUID string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -38,15 +38,15 @@ func (f *PrometheusApiHandler) handleRouteGetRuleStatuses(ctx *models.ReqContext return t.RouteGetRuleStatuses(ctx) } -func (f *PrometheusApiHandler) handleRouteGetGrafanaAlertStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) handleRouteGetGrafanaAlertStatuses(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetAlertStatuses(ctx) } -func (f *PrometheusApiHandler) handleRouteGetGrafanaRuleStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) handleRouteGetGrafanaRuleStatuses(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaSvc.RouteGetRuleStatuses(ctx) } -func (f *PrometheusApiHandler) getService(ctx *models.ReqContext) (*LotexProm, error) { +func (f *PrometheusApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexProm, error) { _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.LoTexRulerBackend) if err != nil { return nil, err diff --git a/pkg/services/ngalert/api/forking_ruler.go b/pkg/services/ngalert/api/forking_ruler.go index b415204bca4..e26afac7abb 100644 --- a/pkg/services/ngalert/api/forking_ruler.go +++ b/pkg/services/ngalert/api/forking_ruler.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -22,7 +22,7 @@ func NewForkingRuler(datasourceCache datasources.CacheService, lotex *LotexRuler } } -func (f *RulerApiHandler) handleRouteDeleteNamespaceRulesConfig(ctx *models.ReqContext, dsUID, namespace string) response.Response { +func (f *RulerApiHandler) handleRouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContext, dsUID, namespace string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -30,7 +30,7 @@ func (f *RulerApiHandler) handleRouteDeleteNamespaceRulesConfig(ctx *models.ReqC return t.RouteDeleteNamespaceRulesConfig(ctx, namespace) } -func (f *RulerApiHandler) handleRouteDeleteRuleGroupConfig(ctx *models.ReqContext, dsUID, namespace, group string) response.Response { +func (f *RulerApiHandler) handleRouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, dsUID, namespace, group string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -38,7 +38,7 @@ func (f *RulerApiHandler) handleRouteDeleteRuleGroupConfig(ctx *models.ReqContex return t.RouteDeleteRuleGroupConfig(ctx, namespace, group) } -func (f *RulerApiHandler) handleRouteGetNamespaceRulesConfig(ctx *models.ReqContext, dsUID, namespace string) response.Response { +func (f *RulerApiHandler) handleRouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, dsUID, namespace string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -46,7 +46,7 @@ func (f *RulerApiHandler) handleRouteGetNamespaceRulesConfig(ctx *models.ReqCont return t.RouteGetNamespaceRulesConfig(ctx, namespace) } -func (f *RulerApiHandler) handleRouteGetRulegGroupConfig(ctx *models.ReqContext, dsUID, namespace, group string) response.Response { +func (f *RulerApiHandler) handleRouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, dsUID, namespace, group string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -54,7 +54,7 @@ func (f *RulerApiHandler) handleRouteGetRulegGroupConfig(ctx *models.ReqContext, return t.RouteGetRulegGroupConfig(ctx, namespace, group) } -func (f *RulerApiHandler) handleRouteGetRulesConfig(ctx *models.ReqContext, dsUID string) response.Response { +func (f *RulerApiHandler) handleRouteGetRulesConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -62,7 +62,7 @@ func (f *RulerApiHandler) handleRouteGetRulesConfig(ctx *models.ReqContext, dsUI return t.RouteGetRulesConfig(ctx) } -func (f *RulerApiHandler) handleRoutePostNameRulesConfig(ctx *models.ReqContext, conf apimodels.PostableRuleGroupConfig, dsUID, namespace string) response.Response { +func (f *RulerApiHandler) handleRoutePostNameRulesConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, dsUID, namespace string) response.Response { t, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -73,27 +73,27 @@ func (f *RulerApiHandler) handleRoutePostNameRulesConfig(ctx *models.ReqContext, return t.RoutePostNameRulesConfig(ctx, conf, namespace) } -func (f *RulerApiHandler) handleRouteDeleteNamespaceGrafanaRulesConfig(ctx *models.ReqContext, namespace string) response.Response { +func (f *RulerApiHandler) handleRouteDeleteNamespaceGrafanaRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { return f.GrafanaRuler.RouteDeleteAlertRules(ctx, namespace, "") } -func (f *RulerApiHandler) handleRouteDeleteGrafanaRuleGroupConfig(ctx *models.ReqContext, namespace, groupName string) response.Response { +func (f *RulerApiHandler) handleRouteDeleteGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext, namespace, groupName string) response.Response { return f.GrafanaRuler.RouteDeleteAlertRules(ctx, namespace, groupName) } -func (f *RulerApiHandler) handleRouteGetNamespaceGrafanaRulesConfig(ctx *models.ReqContext, namespace string) response.Response { +func (f *RulerApiHandler) handleRouteGetNamespaceGrafanaRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { return f.GrafanaRuler.RouteGetNamespaceRulesConfig(ctx, namespace) } -func (f *RulerApiHandler) handleRouteGetGrafanaRuleGroupConfig(ctx *models.ReqContext, namespace, group string) response.Response { +func (f *RulerApiHandler) handleRouteGetGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext, namespace, group string) response.Response { return f.GrafanaRuler.RouteGetRulesGroupConfig(ctx, namespace, group) } -func (f *RulerApiHandler) handleRouteGetGrafanaRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) handleRouteGetGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response { return f.GrafanaRuler.RouteGetRulesConfig(ctx) } -func (f *RulerApiHandler) handleRoutePostNameGrafanaRulesConfig(ctx *models.ReqContext, conf apimodels.PostableRuleGroupConfig, namespace string) response.Response { +func (f *RulerApiHandler) handleRoutePostNameGrafanaRulesConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, namespace string) response.Response { payloadType := conf.Type() if payloadType != apimodels.GrafanaBackend { return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, conf.Type().String())) @@ -101,7 +101,7 @@ func (f *RulerApiHandler) handleRoutePostNameGrafanaRulesConfig(ctx *models.ReqC return f.GrafanaRuler.RoutePostNameRulesConfig(ctx, conf, namespace) } -func (f *RulerApiHandler) getService(ctx *models.ReqContext) (*LotexRuler, error) { +func (f *RulerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexRuler, error) { _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.LoTexRulerBackend) if err != nil { return nil, err diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index c6b35d35fe0..65ec0ad8b5a 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -12,39 +12,39 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type AlertmanagerApi interface { - RouteCreateGrafanaSilence(*models.ReqContext) response.Response - RouteCreateSilence(*models.ReqContext) response.Response - RouteDeleteAlertingConfig(*models.ReqContext) response.Response - RouteDeleteGrafanaAlertingConfig(*models.ReqContext) response.Response - RouteDeleteGrafanaSilence(*models.ReqContext) response.Response - RouteDeleteSilence(*models.ReqContext) response.Response - RouteGetAMAlertGroups(*models.ReqContext) response.Response - RouteGetAMAlerts(*models.ReqContext) response.Response - RouteGetAMStatus(*models.ReqContext) response.Response - RouteGetAlertingConfig(*models.ReqContext) response.Response - RouteGetGrafanaAMAlertGroups(*models.ReqContext) response.Response - RouteGetGrafanaAMAlerts(*models.ReqContext) response.Response - RouteGetGrafanaAMStatus(*models.ReqContext) response.Response - RouteGetGrafanaAlertingConfig(*models.ReqContext) response.Response - RouteGetGrafanaReceivers(*models.ReqContext) response.Response - RouteGetGrafanaSilence(*models.ReqContext) response.Response - RouteGetGrafanaSilences(*models.ReqContext) response.Response - RouteGetSilence(*models.ReqContext) response.Response - RouteGetSilences(*models.ReqContext) response.Response - RoutePostAMAlerts(*models.ReqContext) response.Response - RoutePostAlertingConfig(*models.ReqContext) response.Response - RoutePostGrafanaAlertingConfig(*models.ReqContext) response.Response - RoutePostTestGrafanaReceivers(*models.ReqContext) response.Response + RouteCreateGrafanaSilence(*contextmodel.ReqContext) response.Response + RouteCreateSilence(*contextmodel.ReqContext) response.Response + RouteDeleteAlertingConfig(*contextmodel.ReqContext) response.Response + RouteDeleteGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response + RouteDeleteGrafanaSilence(*contextmodel.ReqContext) response.Response + RouteDeleteSilence(*contextmodel.ReqContext) response.Response + RouteGetAMAlertGroups(*contextmodel.ReqContext) response.Response + RouteGetAMAlerts(*contextmodel.ReqContext) response.Response + RouteGetAMStatus(*contextmodel.ReqContext) response.Response + RouteGetAlertingConfig(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAMAlertGroups(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAMAlerts(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAMStatus(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response + RouteGetGrafanaReceivers(*contextmodel.ReqContext) response.Response + RouteGetGrafanaSilence(*contextmodel.ReqContext) response.Response + RouteGetGrafanaSilences(*contextmodel.ReqContext) response.Response + RouteGetSilence(*contextmodel.ReqContext) response.Response + RouteGetSilences(*contextmodel.ReqContext) response.Response + RoutePostAMAlerts(*contextmodel.ReqContext) response.Response + RoutePostAlertingConfig(*contextmodel.ReqContext) response.Response + RoutePostGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response + RoutePostTestGrafanaReceivers(*contextmodel.ReqContext) response.Response } -func (f *AlertmanagerApiHandler) RouteCreateGrafanaSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteCreateGrafanaSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.PostableSilence{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -52,7 +52,7 @@ func (f *AlertmanagerApiHandler) RouteCreateGrafanaSilence(ctx *models.ReqContex } return f.handleRouteCreateGrafanaSilence(ctx, conf) } -func (f *AlertmanagerApiHandler) RouteCreateSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteCreateSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] // Parse Request Body @@ -62,80 +62,80 @@ func (f *AlertmanagerApiHandler) RouteCreateSilence(ctx *models.ReqContext) resp } return f.handleRouteCreateSilence(ctx, conf, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteDeleteAlertingConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteDeleteAlertingConfig(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteDeleteGrafanaAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteDeleteGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteDeleteGrafanaAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) RouteDeleteGrafanaSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteDeleteGrafanaSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters silenceIdParam := web.Params(ctx.Req)[":SilenceId"] return f.handleRouteDeleteGrafanaSilence(ctx, silenceIdParam) } -func (f *AlertmanagerApiHandler) RouteDeleteSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteDeleteSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters silenceIdParam := web.Params(ctx.Req)[":SilenceId"] datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteDeleteSilence(ctx, silenceIdParam, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetAMAlertGroups(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetAMAlertGroups(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetAMAlerts(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetAMAlerts(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetAMAlerts(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetAMStatus(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetAMStatus(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetAMStatus(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetAlertingConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetAlertingConfig(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaAMAlertGroups(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaAMAlertGroups(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAMAlertGroups(ctx) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaAMAlerts(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaAMAlerts(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAMAlerts(ctx) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaAMStatus(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaAMStatus(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAMStatus(ctx) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAlertingConfig(ctx) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaReceivers(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaReceivers(ctx) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters silenceIdParam := web.Params(ctx.Req)[":SilenceId"] return f.handleRouteGetGrafanaSilence(ctx, silenceIdParam) } -func (f *AlertmanagerApiHandler) RouteGetGrafanaSilences(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetGrafanaSilences(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaSilences(ctx) } -func (f *AlertmanagerApiHandler) RouteGetSilence(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetSilence(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters silenceIdParam := web.Params(ctx.Req)[":SilenceId"] datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetSilence(ctx, silenceIdParam, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RouteGetSilences(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RouteGetSilences(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetSilences(ctx, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RoutePostAMAlerts(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RoutePostAMAlerts(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] // Parse Request Body @@ -145,7 +145,7 @@ func (f *AlertmanagerApiHandler) RoutePostAMAlerts(ctx *models.ReqContext) respo } return f.handleRoutePostAMAlerts(ctx, conf, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RoutePostAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RoutePostAlertingConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] // Parse Request Body @@ -155,7 +155,7 @@ func (f *AlertmanagerApiHandler) RoutePostAlertingConfig(ctx *models.ReqContext) } return f.handleRoutePostAlertingConfig(ctx, conf, datasourceUIDParam) } -func (f *AlertmanagerApiHandler) RoutePostGrafanaAlertingConfig(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RoutePostGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.PostableUserConfig{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -163,7 +163,7 @@ func (f *AlertmanagerApiHandler) RoutePostGrafanaAlertingConfig(ctx *models.ReqC } return f.handleRoutePostGrafanaAlertingConfig(ctx, conf) } -func (f *AlertmanagerApiHandler) RoutePostTestGrafanaReceivers(ctx *models.ReqContext) response.Response { +func (f *AlertmanagerApiHandler) RoutePostTestGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.TestReceiversConfigBodyParams{} if err := web.Bind(ctx.Req, &conf); err != nil { diff --git a/pkg/services/ngalert/api/generated_base_api_configuration.go b/pkg/services/ngalert/api/generated_base_api_configuration.go index 8a8912b96b5..c0685d12a1c 100644 --- a/pkg/services/ngalert/api/generated_base_api_configuration.go +++ b/pkg/services/ngalert/api/generated_base_api_configuration.go @@ -12,33 +12,33 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type ConfigurationApi interface { - RouteDeleteNGalertConfig(*models.ReqContext) response.Response - RouteGetAlertmanagers(*models.ReqContext) response.Response - RouteGetNGalertConfig(*models.ReqContext) response.Response - RouteGetStatus(*models.ReqContext) response.Response - RoutePostNGalertConfig(*models.ReqContext) response.Response + RouteDeleteNGalertConfig(*contextmodel.ReqContext) response.Response + RouteGetAlertmanagers(*contextmodel.ReqContext) response.Response + RouteGetNGalertConfig(*contextmodel.ReqContext) response.Response + RouteGetStatus(*contextmodel.ReqContext) response.Response + RoutePostNGalertConfig(*contextmodel.ReqContext) response.Response } -func (f *ConfigurationApiHandler) RouteDeleteNGalertConfig(ctx *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) RouteDeleteNGalertConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteDeleteNGalertConfig(ctx) } -func (f *ConfigurationApiHandler) RouteGetAlertmanagers(ctx *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) RouteGetAlertmanagers(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetAlertmanagers(ctx) } -func (f *ConfigurationApiHandler) RouteGetNGalertConfig(ctx *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) RouteGetNGalertConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetNGalertConfig(ctx) } -func (f *ConfigurationApiHandler) RouteGetStatus(ctx *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) RouteGetStatus(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetStatus(ctx) } -func (f *ConfigurationApiHandler) RoutePostNGalertConfig(ctx *models.ReqContext) response.Response { +func (f *ConfigurationApiHandler) RoutePostNGalertConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.PostableNGalertConfig{} if err := web.Bind(ctx.Req, &conf); err != nil { diff --git a/pkg/services/ngalert/api/generated_base_api_prometheus.go b/pkg/services/ngalert/api/generated_base_api_prometheus.go index 05297027bc3..a4f04e25199 100644 --- a/pkg/services/ngalert/api/generated_base_api_prometheus.go +++ b/pkg/services/ngalert/api/generated_base_api_prometheus.go @@ -12,30 +12,30 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type PrometheusApi interface { - RouteGetAlertStatuses(*models.ReqContext) response.Response - RouteGetGrafanaAlertStatuses(*models.ReqContext) response.Response - RouteGetGrafanaRuleStatuses(*models.ReqContext) response.Response - RouteGetRuleStatuses(*models.ReqContext) response.Response + RouteGetAlertStatuses(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAlertStatuses(*contextmodel.ReqContext) response.Response + RouteGetGrafanaRuleStatuses(*contextmodel.ReqContext) response.Response + RouteGetRuleStatuses(*contextmodel.ReqContext) response.Response } -func (f *PrometheusApiHandler) RouteGetAlertStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) RouteGetAlertStatuses(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetAlertStatuses(ctx, datasourceUIDParam) } -func (f *PrometheusApiHandler) RouteGetGrafanaAlertStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) RouteGetGrafanaAlertStatuses(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAlertStatuses(ctx) } -func (f *PrometheusApiHandler) RouteGetGrafanaRuleStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) RouteGetGrafanaRuleStatuses(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaRuleStatuses(ctx) } -func (f *PrometheusApiHandler) RouteGetRuleStatuses(ctx *models.ReqContext) response.Response { +func (f *PrometheusApiHandler) RouteGetRuleStatuses(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetRuleStatuses(ctx, datasourceUIDParam) diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index d7d8c8b9776..f3e6ed66c32 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -12,95 +12,112 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type ProvisioningApi interface { - RouteDeleteAlertRule(*models.ReqContext) response.Response - RouteDeleteContactpoints(*models.ReqContext) response.Response - RouteDeleteMuteTiming(*models.ReqContext) response.Response - RouteDeleteTemplate(*models.ReqContext) response.Response - RouteGetAlertRule(*models.ReqContext) response.Response - RouteGetAlertRuleGroup(*models.ReqContext) response.Response - RouteGetAlertRules(*models.ReqContext) response.Response - RouteGetContactpoints(*models.ReqContext) response.Response - RouteGetMuteTiming(*models.ReqContext) response.Response - RouteGetMuteTimings(*models.ReqContext) response.Response - RouteGetPolicyTree(*models.ReqContext) response.Response - RouteGetTemplate(*models.ReqContext) response.Response - RouteGetTemplates(*models.ReqContext) response.Response - RoutePostAlertRule(*models.ReqContext) response.Response - RoutePostContactpoints(*models.ReqContext) response.Response - RoutePostMuteTiming(*models.ReqContext) response.Response - RoutePutAlertRule(*models.ReqContext) response.Response - RoutePutAlertRuleGroup(*models.ReqContext) response.Response - RoutePutContactpoint(*models.ReqContext) response.Response - RoutePutMuteTiming(*models.ReqContext) response.Response - RoutePutPolicyTree(*models.ReqContext) response.Response - RoutePutTemplate(*models.ReqContext) response.Response - RouteResetPolicyTree(*models.ReqContext) response.Response + RouteDeleteAlertRule(*contextmodel.ReqContext) response.Response + RouteDeleteContactpoints(*contextmodel.ReqContext) response.Response + RouteDeleteMuteTiming(*contextmodel.ReqContext) response.Response + RouteDeleteTemplate(*contextmodel.ReqContext) response.Response + RouteGetAlertRule(*contextmodel.ReqContext) response.Response + RouteGetAlertRuleExport(*contextmodel.ReqContext) response.Response + RouteGetAlertRuleGroup(*contextmodel.ReqContext) response.Response + RouteGetAlertRuleGroupExport(*contextmodel.ReqContext) response.Response + RouteGetAlertRules(*contextmodel.ReqContext) response.Response + RouteGetAlertRulesExport(*contextmodel.ReqContext) response.Response + RouteGetContactpoints(*contextmodel.ReqContext) response.Response + RouteGetMuteTiming(*contextmodel.ReqContext) response.Response + RouteGetMuteTimings(*contextmodel.ReqContext) response.Response + RouteGetPolicyTree(*contextmodel.ReqContext) response.Response + RouteGetTemplate(*contextmodel.ReqContext) response.Response + RouteGetTemplates(*contextmodel.ReqContext) response.Response + RoutePostAlertRule(*contextmodel.ReqContext) response.Response + RoutePostContactpoints(*contextmodel.ReqContext) response.Response + RoutePostMuteTiming(*contextmodel.ReqContext) response.Response + RoutePutAlertRule(*contextmodel.ReqContext) response.Response + RoutePutAlertRuleGroup(*contextmodel.ReqContext) response.Response + RoutePutContactpoint(*contextmodel.ReqContext) response.Response + RoutePutMuteTiming(*contextmodel.ReqContext) response.Response + RoutePutPolicyTree(*contextmodel.ReqContext) response.Response + RoutePutTemplate(*contextmodel.ReqContext) response.Response + RouteResetPolicyTree(*contextmodel.ReqContext) response.Response } -func (f *ProvisioningApiHandler) RouteDeleteAlertRule(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteDeleteAlertRule(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] return f.handleRouteDeleteAlertRule(ctx, uIDParam) } -func (f *ProvisioningApiHandler) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteDeleteContactpoints(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] return f.handleRouteDeleteContactpoints(ctx, uIDParam) } -func (f *ProvisioningApiHandler) RouteDeleteMuteTiming(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteDeleteMuteTiming(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] return f.handleRouteDeleteMuteTiming(ctx, nameParam) } -func (f *ProvisioningApiHandler) RouteDeleteTemplate(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteDeleteTemplate(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] return f.handleRouteDeleteTemplate(ctx, nameParam) } -func (f *ProvisioningApiHandler) RouteGetAlertRule(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetAlertRule(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] return f.handleRouteGetAlertRule(ctx, uIDParam) } -func (f *ProvisioningApiHandler) RouteGetAlertRuleGroup(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetAlertRuleExport(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + uIDParam := web.Params(ctx.Req)[":UID"] + return f.handleRouteGetAlertRuleExport(ctx, uIDParam) +} +func (f *ProvisioningApiHandler) RouteGetAlertRuleGroup(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters folderUIDParam := web.Params(ctx.Req)[":FolderUID"] groupParam := web.Params(ctx.Req)[":Group"] return f.handleRouteGetAlertRuleGroup(ctx, folderUIDParam, groupParam) } -func (f *ProvisioningApiHandler) RouteGetAlertRules(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetAlertRuleGroupExport(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + folderUIDParam := web.Params(ctx.Req)[":FolderUID"] + groupParam := web.Params(ctx.Req)[":Group"] + return f.handleRouteGetAlertRuleGroupExport(ctx, folderUIDParam, groupParam) +} +func (f *ProvisioningApiHandler) RouteGetAlertRules(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetAlertRules(ctx) } -func (f *ProvisioningApiHandler) RouteGetContactpoints(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetAlertRulesExport(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteGetAlertRulesExport(ctx) +} +func (f *ProvisioningApiHandler) RouteGetContactpoints(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetContactpoints(ctx) } -func (f *ProvisioningApiHandler) RouteGetMuteTiming(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetMuteTiming(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] return f.handleRouteGetMuteTiming(ctx, nameParam) } -func (f *ProvisioningApiHandler) RouteGetMuteTimings(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetMuteTimings(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetMuteTimings(ctx) } -func (f *ProvisioningApiHandler) RouteGetPolicyTree(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetPolicyTree(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetPolicyTree(ctx) } -func (f *ProvisioningApiHandler) RouteGetTemplate(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetTemplate(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] return f.handleRouteGetTemplate(ctx, nameParam) } -func (f *ProvisioningApiHandler) RouteGetTemplates(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteGetTemplates(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetTemplates(ctx) } -func (f *ProvisioningApiHandler) RoutePostAlertRule(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePostAlertRule(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.ProvisionedAlertRule{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -108,7 +125,7 @@ func (f *ProvisioningApiHandler) RoutePostAlertRule(ctx *models.ReqContext) resp } return f.handleRoutePostAlertRule(ctx, conf) } -func (f *ProvisioningApiHandler) RoutePostContactpoints(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePostContactpoints(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.EmbeddedContactPoint{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -116,7 +133,7 @@ func (f *ProvisioningApiHandler) RoutePostContactpoints(ctx *models.ReqContext) } return f.handleRoutePostContactpoints(ctx, conf) } -func (f *ProvisioningApiHandler) RoutePostMuteTiming(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePostMuteTiming(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.MuteTimeInterval{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -124,7 +141,7 @@ func (f *ProvisioningApiHandler) RoutePostMuteTiming(ctx *models.ReqContext) res } return f.handleRoutePostMuteTiming(ctx, conf) } -func (f *ProvisioningApiHandler) RoutePutAlertRule(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutAlertRule(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] // Parse Request Body @@ -134,7 +151,7 @@ func (f *ProvisioningApiHandler) RoutePutAlertRule(ctx *models.ReqContext) respo } return f.handleRoutePutAlertRule(ctx, conf, uIDParam) } -func (f *ProvisioningApiHandler) RoutePutAlertRuleGroup(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutAlertRuleGroup(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters folderUIDParam := web.Params(ctx.Req)[":FolderUID"] groupParam := web.Params(ctx.Req)[":Group"] @@ -145,7 +162,7 @@ func (f *ProvisioningApiHandler) RoutePutAlertRuleGroup(ctx *models.ReqContext) } return f.handleRoutePutAlertRuleGroup(ctx, conf, folderUIDParam, groupParam) } -func (f *ProvisioningApiHandler) RoutePutContactpoint(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutContactpoint(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters uIDParam := web.Params(ctx.Req)[":UID"] // Parse Request Body @@ -155,7 +172,7 @@ func (f *ProvisioningApiHandler) RoutePutContactpoint(ctx *models.ReqContext) re } return f.handleRoutePutContactpoint(ctx, conf, uIDParam) } -func (f *ProvisioningApiHandler) RoutePutMuteTiming(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutMuteTiming(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] // Parse Request Body @@ -165,7 +182,7 @@ func (f *ProvisioningApiHandler) RoutePutMuteTiming(ctx *models.ReqContext) resp } return f.handleRoutePutMuteTiming(ctx, conf, nameParam) } -func (f *ProvisioningApiHandler) RoutePutPolicyTree(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutPolicyTree(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.Route{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -173,7 +190,7 @@ func (f *ProvisioningApiHandler) RoutePutPolicyTree(ctx *models.ReqContext) resp } return f.handleRoutePutPolicyTree(ctx, conf) } -func (f *ProvisioningApiHandler) RoutePutTemplate(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RoutePutTemplate(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] // Parse Request Body @@ -183,7 +200,7 @@ func (f *ProvisioningApiHandler) RoutePutTemplate(ctx *models.ReqContext) respon } return f.handleRoutePutTemplate(ctx, conf, nameParam) } -func (f *ProvisioningApiHandler) RouteResetPolicyTree(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) RouteResetPolicyTree(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteResetPolicyTree(ctx) } @@ -239,6 +256,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics m, ), ) + group.Get( + toMacaronPath("/api/v1/provisioning/alert-rules/{UID}/export"), + api.authorize(http.MethodGet, "/api/v1/provisioning/alert-rules/{UID}/export"), + metrics.Instrument( + http.MethodGet, + "/api/v1/provisioning/alert-rules/{UID}/export", + srv.RouteGetAlertRuleExport, + m, + ), + ) group.Get( toMacaronPath("/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), api.authorize(http.MethodGet, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), @@ -249,6 +276,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics m, ), ) + group.Get( + toMacaronPath("/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export"), + api.authorize(http.MethodGet, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export"), + metrics.Instrument( + http.MethodGet, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export", + srv.RouteGetAlertRuleGroupExport, + m, + ), + ) group.Get( toMacaronPath("/api/v1/provisioning/alert-rules"), api.authorize(http.MethodGet, "/api/v1/provisioning/alert-rules"), @@ -259,6 +296,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics m, ), ) + group.Get( + toMacaronPath("/api/v1/provisioning/alert-rules/export"), + api.authorize(http.MethodGet, "/api/v1/provisioning/alert-rules/export"), + metrics.Instrument( + http.MethodGet, + "/api/v1/provisioning/alert-rules/export", + srv.RouteGetAlertRulesExport, + m, + ), + ) group.Get( toMacaronPath("/api/v1/provisioning/contact-points"), api.authorize(http.MethodGet, "/api/v1/provisioning/contact-points"), diff --git a/pkg/services/ngalert/api/generated_base_api_ruler.go b/pkg/services/ngalert/api/generated_base_api_ruler.go index ac584615874..b5687db2a3f 100644 --- a/pkg/services/ngalert/api/generated_base_api_ruler.go +++ b/pkg/services/ngalert/api/generated_base_api_ruler.go @@ -12,84 +12,84 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type RulerApi interface { - RouteDeleteGrafanaRuleGroupConfig(*models.ReqContext) response.Response - RouteDeleteNamespaceGrafanaRulesConfig(*models.ReqContext) response.Response - RouteDeleteNamespaceRulesConfig(*models.ReqContext) response.Response - RouteDeleteRuleGroupConfig(*models.ReqContext) response.Response - RouteGetGrafanaRuleGroupConfig(*models.ReqContext) response.Response - RouteGetGrafanaRulesConfig(*models.ReqContext) response.Response - RouteGetNamespaceGrafanaRulesConfig(*models.ReqContext) response.Response - RouteGetNamespaceRulesConfig(*models.ReqContext) response.Response - RouteGetRulegGroupConfig(*models.ReqContext) response.Response - RouteGetRulesConfig(*models.ReqContext) response.Response - RoutePostNameGrafanaRulesConfig(*models.ReqContext) response.Response - RoutePostNameRulesConfig(*models.ReqContext) response.Response + RouteDeleteGrafanaRuleGroupConfig(*contextmodel.ReqContext) response.Response + RouteDeleteNamespaceGrafanaRulesConfig(*contextmodel.ReqContext) response.Response + RouteDeleteNamespaceRulesConfig(*contextmodel.ReqContext) response.Response + RouteDeleteRuleGroupConfig(*contextmodel.ReqContext) response.Response + RouteGetGrafanaRuleGroupConfig(*contextmodel.ReqContext) response.Response + RouteGetGrafanaRulesConfig(*contextmodel.ReqContext) response.Response + RouteGetNamespaceGrafanaRulesConfig(*contextmodel.ReqContext) response.Response + RouteGetNamespaceRulesConfig(*contextmodel.ReqContext) response.Response + RouteGetRulegGroupConfig(*contextmodel.ReqContext) response.Response + RouteGetRulesConfig(*contextmodel.ReqContext) response.Response + RoutePostNameGrafanaRulesConfig(*contextmodel.ReqContext) response.Response + RoutePostNameRulesConfig(*contextmodel.ReqContext) response.Response } -func (f *RulerApiHandler) RouteDeleteGrafanaRuleGroupConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteDeleteGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceParam := web.Params(ctx.Req)[":Namespace"] groupnameParam := web.Params(ctx.Req)[":Groupname"] return f.handleRouteDeleteGrafanaRuleGroupConfig(ctx, namespaceParam, groupnameParam) } -func (f *RulerApiHandler) RouteDeleteNamespaceGrafanaRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteDeleteNamespaceGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceParam := web.Params(ctx.Req)[":Namespace"] return f.handleRouteDeleteNamespaceGrafanaRulesConfig(ctx, namespaceParam) } -func (f *RulerApiHandler) RouteDeleteNamespaceRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] namespaceParam := web.Params(ctx.Req)[":Namespace"] return f.handleRouteDeleteNamespaceRulesConfig(ctx, datasourceUIDParam, namespaceParam) } -func (f *RulerApiHandler) RouteDeleteRuleGroupConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] namespaceParam := web.Params(ctx.Req)[":Namespace"] groupnameParam := web.Params(ctx.Req)[":Groupname"] return f.handleRouteDeleteRuleGroupConfig(ctx, datasourceUIDParam, namespaceParam, groupnameParam) } -func (f *RulerApiHandler) RouteGetGrafanaRuleGroupConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceParam := web.Params(ctx.Req)[":Namespace"] groupnameParam := web.Params(ctx.Req)[":Groupname"] return f.handleRouteGetGrafanaRuleGroupConfig(ctx, namespaceParam, groupnameParam) } -func (f *RulerApiHandler) RouteGetGrafanaRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaRulesConfig(ctx) } -func (f *RulerApiHandler) RouteGetNamespaceGrafanaRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetNamespaceGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceParam := web.Params(ctx.Req)[":Namespace"] return f.handleRouteGetNamespaceGrafanaRulesConfig(ctx, namespaceParam) } -func (f *RulerApiHandler) RouteGetNamespaceRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] namespaceParam := web.Params(ctx.Req)[":Namespace"] return f.handleRouteGetNamespaceRulesConfig(ctx, datasourceUIDParam, namespaceParam) } -func (f *RulerApiHandler) RouteGetRulegGroupConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] namespaceParam := web.Params(ctx.Req)[":Namespace"] groupnameParam := web.Params(ctx.Req)[":Groupname"] return f.handleRouteGetRulegGroupConfig(ctx, datasourceUIDParam, namespaceParam, groupnameParam) } -func (f *RulerApiHandler) RouteGetRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] return f.handleRouteGetRulesConfig(ctx, datasourceUIDParam) } -func (f *RulerApiHandler) RoutePostNameGrafanaRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RoutePostNameGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceParam := web.Params(ctx.Req)[":Namespace"] // Parse Request Body @@ -99,7 +99,7 @@ func (f *RulerApiHandler) RoutePostNameGrafanaRulesConfig(ctx *models.ReqContext } return f.handleRoutePostNameGrafanaRulesConfig(ctx, conf, namespaceParam) } -func (f *RulerApiHandler) RoutePostNameRulesConfig(ctx *models.ReqContext) response.Response { +func (f *RulerApiHandler) RoutePostNameRulesConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] namespaceParam := web.Params(ctx.Req)[":Namespace"] diff --git a/pkg/services/ngalert/api/generated_base_api_testing.go b/pkg/services/ngalert/api/generated_base_api_testing.go index 06304ed4983..6b3e104efab 100644 --- a/pkg/services/ngalert/api/generated_base_api_testing.go +++ b/pkg/services/ngalert/api/generated_base_api_testing.go @@ -12,28 +12,28 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/web" ) type TestingApi interface { - BacktestConfig(*models.ReqContext) response.Response - RouteEvalQueries(*models.ReqContext) response.Response - RouteTestRuleConfig(*models.ReqContext) response.Response - RouteTestRuleGrafanaConfig(*models.ReqContext) response.Response + BacktestConfig(*contextmodel.ReqContext) response.Response + RouteEvalQueries(*contextmodel.ReqContext) response.Response + RouteTestRuleConfig(*contextmodel.ReqContext) response.Response + RouteTestRuleGrafanaConfig(*contextmodel.ReqContext) response.Response } -func (f *TestingApiHandler) BacktestConfig(ctx *models.ReqContext) response.Response { +func (f *TestingApiHandler) BacktestConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.BacktestConfig{} if err := web.Bind(ctx.Req, &conf); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - return f.handleBacktestingConfig(ctx, conf) + return f.handleBacktestConfig(ctx, conf) } -func (f *TestingApiHandler) RouteEvalQueries(ctx *models.ReqContext) response.Response { +func (f *TestingApiHandler) RouteEvalQueries(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.EvalQueriesPayload{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -41,7 +41,7 @@ func (f *TestingApiHandler) RouteEvalQueries(ctx *models.ReqContext) response.Re } return f.handleRouteEvalQueries(ctx, conf) } -func (f *TestingApiHandler) RouteTestRuleConfig(ctx *models.ReqContext) response.Response { +func (f *TestingApiHandler) RouteTestRuleConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"] // Parse Request Body @@ -51,7 +51,7 @@ func (f *TestingApiHandler) RouteTestRuleConfig(ctx *models.ReqContext) response } return f.handleRouteTestRuleConfig(ctx, conf, datasourceUIDParam) } -func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *models.ReqContext) response.Response { +func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body conf := apimodels.TestRulePayload{} if err := web.Bind(ctx.Req, &conf); err != nil { diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index 9db6b947767..0a4ac25864e 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/web" @@ -61,7 +61,7 @@ func NewLotexAM(proxy *AlertingProxy, log log.Logger) *LotexAM { } func (am *LotexAM) withAMReq( - ctx *models.ReqContext, + ctx *contextmodel.ReqContext, method string, endpoint string, pathParams []string, @@ -110,7 +110,7 @@ func (am *LotexAM) withAMReq( ) } -func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteGetAMStatus(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -122,7 +122,7 @@ func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response { ) } -func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimodels.PostableSilence) response.Response { +func (am *LotexAM) RouteCreateSilence(ctx *contextmodel.ReqContext, silenceBody apimodels.PostableSilence) response.Response { blob, err := json.Marshal(silenceBody) if err != nil { return ErrResp(500, err, "Failed marshal silence") @@ -138,7 +138,7 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod ) } -func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteDeleteAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodDelete, @@ -150,7 +150,7 @@ func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Re ) } -func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext, silenceID string) response.Response { +func (am *LotexAM) RouteDeleteSilence(ctx *contextmodel.ReqContext, silenceID string) response.Response { return am.withAMReq( ctx, http.MethodDelete, @@ -162,7 +162,7 @@ func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext, silenceID string) ) } -func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteGetAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -174,7 +174,7 @@ func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Respo ) } -func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteGetAMAlertGroups(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -186,7 +186,7 @@ func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Respon ) } -func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteGetAMAlerts(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -198,7 +198,7 @@ func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response { ) } -func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext, silenceID string) response.Response { +func (am *LotexAM) RouteGetSilence(ctx *contextmodel.ReqContext, silenceID string) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -210,7 +210,7 @@ func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext, silenceID string) res ) } -func (am *LotexAM) RouteGetSilences(ctx *models.ReqContext) response.Response { +func (am *LotexAM) RouteGetSilences(ctx *contextmodel.ReqContext) response.Response { return am.withAMReq( ctx, http.MethodGet, @@ -222,7 +222,7 @@ func (am *LotexAM) RouteGetSilences(ctx *models.ReqContext) response.Response { ) } -func (am *LotexAM) RoutePostAlertingConfig(ctx *models.ReqContext, config apimodels.PostableUserConfig) response.Response { +func (am *LotexAM) RoutePostAlertingConfig(ctx *contextmodel.ReqContext, config apimodels.PostableUserConfig) response.Response { yml, err := yaml.Marshal(&config) if err != nil { return ErrResp(500, err, "Failed marshal alert manager configuration ") @@ -239,7 +239,7 @@ func (am *LotexAM) RoutePostAlertingConfig(ctx *models.ReqContext, config apimod ) } -func (am *LotexAM) RoutePostAMAlerts(ctx *models.ReqContext, alerts apimodels.PostableAlerts) response.Response { +func (am *LotexAM) RoutePostAMAlerts(ctx *contextmodel.ReqContext, alerts apimodels.PostableAlerts) response.Response { yml, err := yaml.Marshal(alerts) if err != nil { return ErrResp(500, err, "Failed marshal postable alerts") diff --git a/pkg/services/ngalert/api/lotex_prom.go b/pkg/services/ngalert/api/lotex_prom.go index f94797a4294..e3be8525bb1 100644 --- a/pkg/services/ngalert/api/lotex_prom.go +++ b/pkg/services/ngalert/api/lotex_prom.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/web" ) @@ -38,7 +38,7 @@ func NewLotexProm(proxy *AlertingProxy, log log.Logger) *LotexProm { } } -func (p *LotexProm) RouteGetAlertStatuses(ctx *models.ReqContext) response.Response { +func (p *LotexProm) RouteGetAlertStatuses(ctx *contextmodel.ReqContext) response.Response { endpoints, err := p.getEndpoints(ctx) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -57,7 +57,7 @@ func (p *LotexProm) RouteGetAlertStatuses(ctx *models.ReqContext) response.Respo ) } -func (p *LotexProm) RouteGetRuleStatuses(ctx *models.ReqContext) response.Response { +func (p *LotexProm) RouteGetRuleStatuses(ctx *contextmodel.ReqContext) response.Response { endpoints, err := p.getEndpoints(ctx) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -76,7 +76,7 @@ func (p *LotexProm) RouteGetRuleStatuses(ctx *models.ReqContext) response.Respon ) } -func (p *LotexProm) getEndpoints(ctx *models.ReqContext) (*promEndpoints, error) { +func (p *LotexProm) getEndpoints(ctx *contextmodel.ReqContext) (*promEndpoints, error) { datasourceUID := web.Params(ctx.Req)[":DatasourceUID"] if datasourceUID == "" { return nil, fmt.Errorf("datasource UID is invalid") diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index 1b1dc49289c..9f94a9be7ad 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -6,13 +6,13 @@ import ( "net/http" "net/url" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/web" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" ) const ( @@ -55,7 +55,7 @@ func NewLotexRuler(proxy *AlertingProxy, log log.Logger) *LotexRuler { } } -func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *models.ReqContext, namespace string) response.Response { +func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -73,7 +73,7 @@ func (r *LotexRuler) RouteDeleteNamespaceRulesConfig(ctx *models.ReqContext, nam ) } -func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *models.ReqContext, namespace string, group string) response.Response { +func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *contextmodel.ReqContext, namespace string, group string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -96,7 +96,7 @@ func (r *LotexRuler) RouteDeleteRuleGroupConfig(ctx *models.ReqContext, namespac ) } -func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *models.ReqContext, namespace string) response.Response { +func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *contextmodel.ReqContext, namespace string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -118,7 +118,7 @@ func (r *LotexRuler) RouteGetNamespaceRulesConfig(ctx *models.ReqContext, namesp ) } -func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *models.ReqContext, namespace string, group string) response.Response { +func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext, namespace string, group string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -141,7 +141,7 @@ func (r *LotexRuler) RouteGetRulegGroupConfig(ctx *models.ReqContext, namespace ) } -func (r *LotexRuler) RouteGetRulesConfig(ctx *models.ReqContext) response.Response { +func (r *LotexRuler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -160,7 +160,7 @@ func (r *LotexRuler) RouteGetRulesConfig(ctx *models.ReqContext) response.Respon ) } -func (r *LotexRuler) RoutePostNameRulesConfig(ctx *models.ReqContext, conf apimodels.PostableRuleGroupConfig, ns string) response.Response { +func (r *LotexRuler) RoutePostNameRulesConfig(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, ns string) response.Response { legacyRulerPrefix, err := r.validateAndGetPrefix(ctx) if err != nil { return ErrResp(500, err, "") @@ -173,7 +173,7 @@ func (r *LotexRuler) RoutePostNameRulesConfig(ctx *models.ReqContext, conf apimo return r.withReq(ctx, http.MethodPost, u, bytes.NewBuffer(yml), jsonExtractor(nil), nil) } -func (r *LotexRuler) validateAndGetPrefix(ctx *models.ReqContext) (string, error) { +func (r *LotexRuler) validateAndGetPrefix(ctx *contextmodel.ReqContext) (string, error) { datasourceUID := web.Params(ctx.Req)[":DatasourceUID"] if datasourceUID == "" { return "", fmt.Errorf("datasource UID is invalid") diff --git a/pkg/services/ngalert/api/lotex_ruler_test.go b/pkg/services/ngalert/api/lotex_ruler_test.go index a9800a90406..8631782e358 100644 --- a/pkg/services/ngalert/api/lotex_ruler_test.go +++ b/pkg/services/ngalert/api/lotex_ruler_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/user" @@ -98,7 +98,7 @@ func TestLotexRuler_ValidateAndGetPrefix(t *testing.T) { // Setup request context. httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com"+tt.urlParams, nil) require.NoError(t, err) - ctx := &models.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + ctx := &contextmodel.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} prefix, err := ruler.validateAndGetPrefix(ctx) require.Equal(t, tt.expected, prefix) diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 10c7e3747c7..e7b84c85843 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -22,7 +22,7 @@ type RuleStore interface { DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace - IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) + IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) Count(ctx context.Context, orgID int64) (int64, error) } diff --git a/pkg/services/ngalert/api/provisioning.go b/pkg/services/ngalert/api/provisioning.go index 6dc74c75c1c..d26b6bb5e75 100644 --- a/pkg/services/ngalert/api/provisioning.go +++ b/pkg/services/ngalert/api/provisioning.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -16,94 +16,106 @@ func NewProvisioningApi(svc *ProvisioningSrv) *ProvisioningApiHandler { } } -func (f *ProvisioningApiHandler) handleRouteGetPolicyTree(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetPolicyTree(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetPolicyTree(ctx) } -func (f *ProvisioningApiHandler) handleRoutePutPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response { +func (f *ProvisioningApiHandler) handleRoutePutPolicyTree(ctx *contextmodel.ReqContext, route apimodels.Route) response.Response { return f.svc.RoutePutPolicyTree(ctx, route) } -func (f *ProvisioningApiHandler) handleRouteGetContactpoints(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetContactpoints(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetContactPoints(ctx) } -func (f *ProvisioningApiHandler) handleRoutePostContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { +func (f *ProvisioningApiHandler) handleRoutePostContactpoints(ctx *contextmodel.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { return f.svc.RoutePostContactPoint(ctx, cp) } -func (f *ProvisioningApiHandler) handleRoutePutContactpoint(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint, UID string) response.Response { +func (f *ProvisioningApiHandler) handleRoutePutContactpoint(ctx *contextmodel.ReqContext, cp apimodels.EmbeddedContactPoint, UID string) response.Response { return f.svc.RoutePutContactPoint(ctx, cp, UID) } -func (f *ProvisioningApiHandler) handleRouteDeleteContactpoints(ctx *models.ReqContext, UID string) response.Response { +func (f *ProvisioningApiHandler) handleRouteDeleteContactpoints(ctx *contextmodel.ReqContext, UID string) response.Response { return f.svc.RouteDeleteContactPoint(ctx, UID) } -func (f *ProvisioningApiHandler) handleRouteGetTemplates(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetTemplates(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetTemplates(ctx) } -func (f *ProvisioningApiHandler) handleRouteGetTemplate(ctx *models.ReqContext, name string) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetTemplate(ctx *contextmodel.ReqContext, name string) response.Response { return f.svc.RouteGetTemplate(ctx, name) } -func (f *ProvisioningApiHandler) handleRoutePutTemplate(ctx *models.ReqContext, body apimodels.NotificationTemplateContent, name string) response.Response { +func (f *ProvisioningApiHandler) handleRoutePutTemplate(ctx *contextmodel.ReqContext, body apimodels.NotificationTemplateContent, name string) response.Response { return f.svc.RoutePutTemplate(ctx, body, name) } -func (f *ProvisioningApiHandler) handleRouteDeleteTemplate(ctx *models.ReqContext, name string) response.Response { +func (f *ProvisioningApiHandler) handleRouteDeleteTemplate(ctx *contextmodel.ReqContext, name string) response.Response { return f.svc.RouteDeleteTemplate(ctx, name) } -func (f *ProvisioningApiHandler) handleRouteGetMuteTiming(ctx *models.ReqContext, name string) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetMuteTiming(ctx *contextmodel.ReqContext, name string) response.Response { return f.svc.RouteGetMuteTiming(ctx, name) } -func (f *ProvisioningApiHandler) handleRouteGetMuteTimings(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetMuteTimings(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetMuteTimings(ctx) } -func (f *ProvisioningApiHandler) handleRoutePostMuteTiming(ctx *models.ReqContext, mt apimodels.MuteTimeInterval) response.Response { +func (f *ProvisioningApiHandler) handleRoutePostMuteTiming(ctx *contextmodel.ReqContext, mt apimodels.MuteTimeInterval) response.Response { return f.svc.RoutePostMuteTiming(ctx, mt) } -func (f *ProvisioningApiHandler) handleRoutePutMuteTiming(ctx *models.ReqContext, mt apimodels.MuteTimeInterval, name string) response.Response { +func (f *ProvisioningApiHandler) handleRoutePutMuteTiming(ctx *contextmodel.ReqContext, mt apimodels.MuteTimeInterval, name string) response.Response { return f.svc.RoutePutMuteTiming(ctx, mt, name) } -func (f *ProvisioningApiHandler) handleRouteDeleteMuteTiming(ctx *models.ReqContext, name string) response.Response { +func (f *ProvisioningApiHandler) handleRouteDeleteMuteTiming(ctx *contextmodel.ReqContext, name string) response.Response { return f.svc.RouteDeleteMuteTiming(ctx, name) } -func (f *ProvisioningApiHandler) handleRouteGetAlertRules(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetAlertRules(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteGetAlertRules(ctx) } -func (f *ProvisioningApiHandler) handleRouteGetAlertRule(ctx *models.ReqContext, UID string) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetAlertRule(ctx *contextmodel.ReqContext, UID string) response.Response { return f.svc.RouteRouteGetAlertRule(ctx, UID) } -func (f *ProvisioningApiHandler) handleRoutePostAlertRule(ctx *models.ReqContext, ar apimodels.ProvisionedAlertRule) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetAlertRuleExport(ctx *contextmodel.ReqContext, UID string) response.Response { + return f.svc.RouteGetAlertRuleExport(ctx, UID) +} + +func (f *ProvisioningApiHandler) handleRouteGetAlertRulesExport(ctx *contextmodel.ReqContext) response.Response { + return f.svc.RouteGetAlertRulesExport(ctx) +} + +func (f *ProvisioningApiHandler) handleRoutePostAlertRule(ctx *contextmodel.ReqContext, ar apimodels.ProvisionedAlertRule) response.Response { return f.svc.RoutePostAlertRule(ctx, ar) } -func (f *ProvisioningApiHandler) handleRoutePutAlertRule(ctx *models.ReqContext, ar apimodels.ProvisionedAlertRule, UID string) response.Response { +func (f *ProvisioningApiHandler) handleRoutePutAlertRule(ctx *contextmodel.ReqContext, ar apimodels.ProvisionedAlertRule, UID string) response.Response { return f.svc.RoutePutAlertRule(ctx, ar, UID) } -func (f *ProvisioningApiHandler) handleRouteDeleteAlertRule(ctx *models.ReqContext, UID string) response.Response { +func (f *ProvisioningApiHandler) handleRouteDeleteAlertRule(ctx *contextmodel.ReqContext, UID string) response.Response { return f.svc.RouteDeleteAlertRule(ctx, UID) } -func (f *ProvisioningApiHandler) handleRouteResetPolicyTree(ctx *models.ReqContext) response.Response { +func (f *ProvisioningApiHandler) handleRouteResetPolicyTree(ctx *contextmodel.ReqContext) response.Response { return f.svc.RouteResetPolicyTree(ctx) } -func (f *ProvisioningApiHandler) handleRouteGetAlertRuleGroup(ctx *models.ReqContext, folder, group string) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetAlertRuleGroup(ctx *contextmodel.ReqContext, folder, group string) response.Response { return f.svc.RouteGetAlertRuleGroup(ctx, folder, group) } -func (f *ProvisioningApiHandler) handleRoutePutAlertRuleGroup(ctx *models.ReqContext, ag apimodels.AlertRuleGroup, folder, group string) response.Response { +func (f *ProvisioningApiHandler) handleRouteGetAlertRuleGroupExport(ctx *contextmodel.ReqContext, folder, group string) response.Response { + return f.svc.RouteGetAlertRuleGroupExport(ctx, folder, group) +} + +func (f *ProvisioningApiHandler) handleRoutePutAlertRuleGroup(ctx *contextmodel.ReqContext, ag apimodels.AlertRuleGroup, folder, group string) response.Response { return f.svc.RoutePutAlertRuleGroup(ctx, ag, folder, group) } diff --git a/pkg/services/ngalert/api/testing_api.go b/pkg/services/ngalert/api/testing_api.go index b960717f41f..23da0884de3 100644 --- a/pkg/services/ngalert/api/testing_api.go +++ b/pkg/services/ngalert/api/testing_api.go @@ -2,7 +2,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -17,18 +17,18 @@ func NewTestingApi(svc *TestingApiSrv) *TestingApiHandler { } } -func (f *TestingApiHandler) handleRouteTestRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload, dsUID string) response.Response { +func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, dsUID string) response.Response { return f.svc.RouteTestRuleConfig(c, body, dsUID) } -func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response { +func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response { return f.svc.RouteTestGrafanaRuleConfig(c, body) } -func (f *TestingApiHandler) handleRouteEvalQueries(c *models.ReqContext, body apimodels.EvalQueriesPayload) response.Response { +func (f *TestingApiHandler) handleRouteEvalQueries(c *contextmodel.ReqContext, body apimodels.EvalQueriesPayload) response.Response { return f.svc.RouteEvalQueries(c, body) } -func (f *TestingApiHandler) handleBacktestingConfig(ctx *models.ReqContext, conf apimodels.BacktestConfig) response.Response { +func (f *TestingApiHandler) handleBacktestConfig(ctx *contextmodel.ReqContext, conf apimodels.BacktestConfig) response.Response { return f.svc.BacktestAlertRule(ctx, conf) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 1792ef28f17..5c39db61c9e 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -7,26 +7,6 @@ "Ack": { "type": "object" }, - "AddCommand": { - "properties": { - "name": { - "type": "string" - }, - "role": { - "enum": [ - "Viewer", - "Editor", - "Admin" - ], - "type": "string" - }, - "secondsToLive": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, "Alert": { "properties": { "activeAt": { @@ -141,6 +121,28 @@ "title": "AlertQuery represents a single query associated with an alert definition.", "type": "object" }, + "AlertQueryExport": { + "properties": { + "datasourceUid": { + "type": "string" + }, + "model": { + "additionalProperties": {}, + "type": "object" + }, + "queryType": { + "type": "string" + }, + "refId": { + "type": "string" + }, + "relativeTimeRange": { + "$ref": "#/definitions/RelativeTimeRange" + } + }, + "title": "AlertQueryExport is the provisioned export of models.AlertQuery.", + "type": "object" + }, "AlertResponse": { "properties": { "data": { @@ -161,6 +163,65 @@ ], "type": "object" }, + "AlertRuleExport": { + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "condition": { + "type": "string" + }, + "dasboardUid": { + "type": "string" + }, + "data": { + "items": { + "$ref": "#/definitions/AlertQueryExport" + }, + "type": "array" + }, + "execErrState": { + "enum": [ + "Alerting", + "Error", + "OK" + ], + "type": "string" + }, + "for": { + "$ref": "#/definitions/Duration" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "noDataState": { + "enum": [ + "Alerting", + "NoData", + "OK" + ], + "type": "string" + }, + "panelId": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "uid": { + "type": "string" + } + }, + "title": "AlertRuleExport is the provisioned file export of models.AlertRule.", + "type": "object" + }, "AlertRuleGroup": { "properties": { "folderUid": { @@ -182,6 +243,31 @@ }, "type": "object" }, + "AlertRuleGroupExport": { + "properties": { + "folder": { + "type": "string" + }, + "interval": { + "$ref": "#/definitions/Duration" + }, + "name": { + "type": "string" + }, + "orgId": { + "format": "int64", + "type": "integer" + }, + "rules": { + "items": { + "$ref": "#/definitions/AlertRuleExport" + }, + "type": "array" + } + }, + "title": "AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1.", + "type": "object" + }, "AlertRuleGroupMetadata": { "properties": { "interval": { @@ -191,8 +277,21 @@ }, "type": "object" }, - "AlertStateType": { - "type": "string" + "AlertingFileExport": { + "properties": { + "apiVersion": { + "format": "int64", + "type": "integer" + }, + "groups": { + "items": { + "$ref": "#/definitions/AlertRuleGroupExport" + }, + "type": "array" + } + }, + "title": "AlertingFileExport is the full provisioned file export.", + "type": "object" }, "AlertingRule": { "description": "adapted from cortex", @@ -452,9 +551,31 @@ "type": "object" }, "DataTopic": { + "description": "nolint:revive", "title": "DataTopic is used to identify which topic the frame should be assigned to.", "type": "string" }, + "DiscordConfig": { + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + } + }, + "title": "DiscordConfig configures notifications via Discord.", + "type": "object" + }, "DiscoveryBase": { "properties": { "error": { @@ -902,6 +1023,12 @@ }, "GettableApiReceiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -960,6 +1087,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -1025,7 +1158,7 @@ "type": "object" }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -1257,9 +1390,15 @@ "victorops_api_key": { "$ref": "#/definitions/Secret" }, + "victorops_api_key_file": { + "type": "string" + }, "victorops_api_url": { "$ref": "#/definitions/URL" }, + "webex_api_url": { + "$ref": "#/definitions/URL" + }, "wechat_api_corp_id": { "type": "string" }, @@ -1298,6 +1437,9 @@ "oauth2": { "$ref": "#/definitions/OAuth2" }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -1308,6 +1450,15 @@ "title": "HTTPClientConfig configures an HTTP client.", "type": "object" }, + "Header": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/Secret" + }, + "type": "array" + }, + "type": "object" + }, "HostPort": { "properties": { "Host": { @@ -1401,82 +1552,6 @@ }, "type": "array" }, - "LegacyAlert": { - "properties": { - "Created": { - "format": "date-time", - "type": "string" - }, - "DashboardId": { - "format": "int64", - "type": "integer" - }, - "EvalData": { - "$ref": "#/definitions/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/definitions/Duration" - }, - "Frequency": { - "format": "int64", - "type": "integer" - }, - "Handler": { - "format": "int64", - "type": "integer" - }, - "Id": { - "format": "int64", - "type": "integer" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "format": "date-time", - "type": "string" - }, - "OrgId": { - "format": "int64", - "type": "integer" - }, - "PanelId": { - "format": "int64", - "type": "integer" - }, - "Settings": { - "$ref": "#/definitions/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/definitions/AlertStateType" - }, - "StateChanges": { - "format": "int64", - "type": "integer" - }, - "Updated": { - "format": "date-time", - "type": "string" - }, - "Version": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, "MatchRegexps": { "additionalProperties": { "$ref": "#/definitions/Regexp" @@ -1772,15 +1847,24 @@ "routing_key": { "$ref": "#/definitions/Secret" }, + "routing_key_file": { + "type": "string" + }, "send_resolved": { "type": "boolean" }, "service_key": { "$ref": "#/definitions/Secret" }, + "service_key_file": { + "type": "string" + }, "severity": { "type": "string" }, + "source": { + "type": "string" + }, "url": { "$ref": "#/definitions/URL" } @@ -1870,6 +1954,12 @@ }, "PostableApiReceiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -1928,6 +2018,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -1990,7 +2086,7 @@ "type": "object" }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -2276,6 +2372,9 @@ "token": { "$ref": "#/definitions/Secret" }, + "token_file": { + "type": "string" + }, "url": { "type": "string" }, @@ -2284,6 +2383,9 @@ }, "user_key": { "$ref": "#/definitions/Secret" + }, + "user_key_file": { + "type": "string" } }, "type": "object" @@ -2369,8 +2471,17 @@ "title": "QueryStat is used for storing arbitrary statistics metadata related to a query and its result, e.g. total request time, data processing time.", "type": "object" }, + "RawMessage": { + "type": "object" + }, "Receiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -2423,6 +2534,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -2924,6 +3041,9 @@ "description": "The client key file for the targets.", "type": "string" }, + "max_version": { + "$ref": "#/definitions/TLSVersion" + }, "min_version": { "$ref": "#/definitions/TLSVersion" }, @@ -3231,7 +3351,7 @@ "$ref": "#/definitions/Secret" }, "api_key_file": { - "$ref": "#/definitions/Secret" + "type": "string" }, "api_url": { "$ref": "#/definitions/URL" @@ -3271,6 +3391,27 @@ "title": "VisType is used to indicate how the data should be visualized in explore.", "type": "string" }, + "WebexConfig": { + "properties": { + "api_url": { + "$ref": "#/definitions/URL" + }, + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "room_id": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + } + }, + "title": "WebexConfig configures notifications via Webex.", + "type": "object" + }, "WebhookConfig": { "properties": { "http_config": { @@ -3348,7 +3489,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3532,6 +3672,7 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, @@ -3735,6 +3876,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3947,6 +4089,35 @@ ] } }, + "/api/v1/provisioning/alert-rules/export": { + "get": { + "operationId": "RouteGetAlertRulesExport", + "parameters": [ + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export all alert rules in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/alert-rules/{UID}": { "delete": { "operationId": "RouteDeleteAlertRule", @@ -4042,6 +4213,47 @@ ] } }, + "/api/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "operationId": "RouteGetAlertRuleExport", + "parameters": [ + { + "description": "Alert rule UID", + "in": "path", + "name": "UID", + "required": true, + "type": "string" + }, + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export an alert rule in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/contact-points": { "get": { "operationId": "RouteGetContactpoints", @@ -4245,6 +4457,52 @@ ] } }, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "get": { + "operationId": "RouteGetAlertRuleGroupExport", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + }, + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export an alert rule group in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/mute-timings": { "get": { "operationId": "RouteGetMuteTimings", 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 8bf8c656a5c..0e51a514bbf 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -4,6 +4,8 @@ import ( "time" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/provisioning/alerting/file" + "github.com/prometheus/common/model" ) @@ -14,6 +16,14 @@ import ( // Responses: // 200: ProvisionedAlertRules +// swagger:route GET /api/v1/provisioning/alert-rules/export provisioning stable RouteGetAlertRulesExport +// +// Export all alert rules in provisioning file format. +// +// Responses: +// 200: AlertingFileExport +// 404: description: Not found. + // swagger:route GET /api/v1/provisioning/alert-rules/{UID} provisioning stable RouteGetAlertRule // // Get a specific alert rule by UID. @@ -22,6 +32,19 @@ import ( // 200: ProvisionedAlertRule // 404: description: Not found. +// swagger:route GET /api/v1/provisioning/alert-rules/{UID}/export provisioning stable RouteGetAlertRuleExport +// +// Export an alert rule in provisioning file format. +// +// Produces: +// - application/json +// - application/yaml +// - text/yaml +// +// Responses: +// 200: AlertingFileExport +// 404: description: Not found. + // swagger:route POST /api/v1/provisioning/alert-rules provisioning stable RoutePostAlertRule // // Create a new alert rule. @@ -51,7 +74,7 @@ import ( // Responses: // 204: description: The alert rule was deleted successfully. -// swagger:parameters RouteGetAlertRule RoutePutAlertRule RouteDeleteAlertRule +// swagger:parameters RouteGetAlertRule RoutePutAlertRule RouteDeleteAlertRule RouteGetAlertRuleExport type AlertRuleUIDReference struct { // Alert rule UID // in:path @@ -168,6 +191,19 @@ func NewAlertRules(rules []*models.AlertRule) ProvisionedAlertRules { // 200: AlertRuleGroup // 404: description: Not found. +// swagger:route GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export provisioning stable RouteGetAlertRuleGroupExport +// +// Export an alert rule group in provisioning file format. +// +// Produces: +// - application/json +// - application/yaml +// - text/yaml +// +// Responses: +// 200: AlertingFileExport +// 404: description: Not found. + // swagger:route PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RoutePutAlertRuleGroup // // Update the interval of a rule group. @@ -179,13 +215,13 @@ func NewAlertRules(rules []*models.AlertRule) ProvisionedAlertRules { // 200: AlertRuleGroup // 400: ValidationError -// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport type FolderUIDPathParam struct { // in:path FolderUID string `json:"FolderUID"` } -// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup RouteGetAlertRuleGroupExport type RuleGroupPathParam struct { // in:path Group string `json:"Group"` @@ -202,6 +238,15 @@ type AlertRuleGroupMetadata struct { Interval int64 `json:"interval"` } +// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetAlertRulesExport +type ExportQueryParams struct { + // Whether to initiate a download of the file or not. + // in: query + // required: false + // default: false + Download bool `json:"download"` +} + // swagger:model type AlertRuleGroup struct { Title string `json:"title"` @@ -210,6 +255,10 @@ type AlertRuleGroup struct { Rules []ProvisionedAlertRule `json:"rules"` } +// AlertingFileExport is the full provisioned file export. +// swagger:model +type AlertingFileExport = file.AlertingFileExport + func (a *AlertRuleGroup) ToModel() (models.AlertRuleGroup, error) { ruleGroup := models.AlertRuleGroup{ Title: a.Title, diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 92bad3b8e77..4195d5fd3f7 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -176,6 +176,12 @@ type ResponseDetails struct { Msg string `json:"msg"` } +// swagger:parameters BacktestConfig +type BacktestConfigRequest struct { + // in:body + Body BacktestConfig +} + // swagger:model type BacktestConfig struct { From time.Time `json:"from"` diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index f57cf62cdf4..b30fb5961d6 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -121,6 +121,28 @@ "title": "AlertQuery represents a single query associated with an alert definition.", "type": "object" }, + "AlertQueryExport": { + "properties": { + "datasourceUid": { + "type": "string" + }, + "model": { + "additionalProperties": {}, + "type": "object" + }, + "queryType": { + "type": "string" + }, + "refId": { + "type": "string" + }, + "relativeTimeRange": { + "$ref": "#/definitions/RelativeTimeRange" + } + }, + "title": "AlertQueryExport is the provisioned export of models.AlertQuery.", + "type": "object" + }, "AlertResponse": { "properties": { "data": { @@ -141,6 +163,77 @@ ], "type": "object" }, + "AlertRuleExport": { + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "condition": { + "type": "string" + }, + "dasboardUid": { + "type": "string" + }, + "data": { + "items": { + "$ref": "#/definitions/AlertQueryExport" + }, + "type": "array" + }, + "execErrState": { + "enum": [ + "Alerting", + "Error", + "OK" + ], + "type": "string" + }, + "for": { + "$ref": "#/definitions/Duration" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "noDataState": { + "enum": [ + "Alerting", + "NoData", + "OK" + ], + "type": "string" + }, + "panelId": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "uid": { + "type": "string" + } + }, + "title": "AlertRuleExport is the provisioned export of models.AlertRule.", + "type": "object" + }, + "AlertRuleFileExport": { + "properties": { + "groups": { + "items": { + "$ref": "#/definitions/AlertRuleGroupExport" + }, + "type": "array" + } + }, + "title": "AlertRuleFileExport is the provisioned export of multiple models.AlertRuleGroup.", + "type": "object" + }, "AlertRuleGroup": { "properties": { "folderUid": { @@ -162,6 +255,31 @@ }, "type": "object" }, + "AlertRuleGroupExport": { + "properties": { + "folder": { + "type": "string" + }, + "interval": { + "$ref": "#/definitions/Duration" + }, + "name": { + "type": "string" + }, + "orgId": { + "format": "int64", + "type": "integer" + }, + "rules": { + "items": { + "$ref": "#/definitions/AlertRuleExport" + }, + "type": "array" + } + }, + "title": "AlertRuleGroupExport is the provisioned export of models.AlertRuleGroup.", + "type": "object" + }, "AlertRuleGroupMetadata": { "properties": { "interval": { @@ -171,9 +289,6 @@ }, "type": "object" }, - "AlertStateType": { - "type": "string" - }, "AlertingRule": { "description": "adapted from cortex", "properties": { @@ -432,9 +547,31 @@ "type": "object" }, "DataTopic": { + "description": "nolint:revive", "title": "DataTopic is used to identify which topic the frame should be assigned to.", "type": "string" }, + "DiscordConfig": { + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + } + }, + "title": "DiscordConfig configures notifications via Discord.", + "type": "object" + }, "DiscoveryBase": { "properties": { "error": { @@ -882,6 +1019,12 @@ }, "GettableApiReceiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -940,6 +1083,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -1005,7 +1154,7 @@ "type": "object" }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -1237,9 +1386,15 @@ "victorops_api_key": { "$ref": "#/definitions/Secret" }, + "victorops_api_key_file": { + "type": "string" + }, "victorops_api_url": { "$ref": "#/definitions/URL" }, + "webex_api_url": { + "$ref": "#/definitions/URL" + }, "wechat_api_corp_id": { "type": "string" }, @@ -1278,6 +1433,9 @@ "oauth2": { "$ref": "#/definitions/OAuth2" }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -1288,6 +1446,15 @@ "title": "HTTPClientConfig configures an HTTP client.", "type": "object" }, + "Header": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/Secret" + }, + "type": "array" + }, + "type": "object" + }, "HostPort": { "properties": { "Host": { @@ -1381,82 +1548,6 @@ }, "type": "array" }, - "LegacyAlert": { - "properties": { - "Created": { - "format": "date-time", - "type": "string" - }, - "DashboardId": { - "format": "int64", - "type": "integer" - }, - "EvalData": { - "$ref": "#/definitions/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/definitions/Duration" - }, - "Frequency": { - "format": "int64", - "type": "integer" - }, - "Handler": { - "format": "int64", - "type": "integer" - }, - "Id": { - "format": "int64", - "type": "integer" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "format": "date-time", - "type": "string" - }, - "OrgId": { - "format": "int64", - "type": "integer" - }, - "PanelId": { - "format": "int64", - "type": "integer" - }, - "Settings": { - "$ref": "#/definitions/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/definitions/AlertStateType" - }, - "StateChanges": { - "format": "int64", - "type": "integer" - }, - "Updated": { - "format": "date-time", - "type": "string" - }, - "Version": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, "MatchRegexps": { "additionalProperties": { "$ref": "#/definitions/Regexp" @@ -1752,15 +1843,24 @@ "routing_key": { "$ref": "#/definitions/Secret" }, + "routing_key_file": { + "type": "string" + }, "send_resolved": { "type": "boolean" }, "service_key": { "$ref": "#/definitions/Secret" }, + "service_key_file": { + "type": "string" + }, "severity": { "type": "string" }, + "source": { + "type": "string" + }, "url": { "$ref": "#/definitions/URL" } @@ -1850,6 +1950,12 @@ }, "PostableApiReceiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -1908,6 +2014,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -1970,7 +2082,7 @@ "type": "object" }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -2256,6 +2368,9 @@ "token": { "$ref": "#/definitions/Secret" }, + "token_file": { + "type": "string" + }, "url": { "type": "string" }, @@ -2264,6 +2379,9 @@ }, "user_key": { "$ref": "#/definitions/Secret" + }, + "user_key_file": { + "type": "string" } }, "type": "object" @@ -2349,8 +2467,17 @@ "title": "QueryStat is used for storing arbitrary statistics metadata related to a query and its result, e.g. total request time, data processing time.", "type": "object" }, + "RawMessage": { + "type": "object" + }, "Receiver": { "properties": { + "discord_configs": { + "items": { + "$ref": "#/definitions/DiscordConfig" + }, + "type": "array" + }, "email_configs": { "items": { "$ref": "#/definitions/EmailConfig" @@ -2403,6 +2530,12 @@ }, "type": "array" }, + "webex_configs": { + "items": { + "$ref": "#/definitions/WebexConfig" + }, + "type": "array" + }, "webhook_configs": { "items": { "$ref": "#/definitions/WebhookConfig" @@ -2904,6 +3037,9 @@ "description": "The client key file for the targets.", "type": "string" }, + "max_version": { + "$ref": "#/definitions/TLSVersion" + }, "min_version": { "$ref": "#/definitions/TLSVersion" }, @@ -3137,6 +3273,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -3172,7 +3309,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -3211,7 +3348,7 @@ "$ref": "#/definitions/Secret" }, "api_key_file": { - "$ref": "#/definitions/Secret" + "type": "string" }, "api_url": { "$ref": "#/definitions/URL" @@ -3251,6 +3388,27 @@ "title": "VisType is used to indicate how the data should be visualized in explore.", "type": "string" }, + "WebexConfig": { + "properties": { + "api_url": { + "$ref": "#/definitions/URL" + }, + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "room_id": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + } + }, + "title": "WebexConfig configures notifications via Webex.", + "type": "object" + }, "WebhookConfig": { "properties": { "http_config": { @@ -3328,6 +3486,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3455,7 +3614,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3517,6 +3675,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3752,6 +3911,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -5598,6 +5758,35 @@ ] } }, + "/api/v1/provisioning/alert-rules/export": { + "get": { + "operationId": "RouteGetAlertRulesExport", + "parameters": [ + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "responses": { + "200": { + "description": "AlertRuleFileExport", + "schema": { + "$ref": "#/definitions/AlertRuleFileExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export all alert rules in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/alert-rules/{UID}": { "delete": { "operationId": "RouteDeleteAlertRule", @@ -5693,6 +5882,47 @@ ] } }, + "/api/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "operationId": "RouteGetAlertRuleExport", + "parameters": [ + { + "description": "Alert rule UID", + "in": "path", + "name": "UID", + "required": true, + "type": "string" + }, + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "responses": { + "200": { + "description": "AlertRuleExport", + "schema": { + "$ref": "#/definitions/AlertRuleExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export an alert rule in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/contact-points": { "get": { "operationId": "RouteGetContactpoints", @@ -5896,6 +6126,52 @@ ] } }, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "get": { + "operationId": "RouteGetAlertRuleGroupExport", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + }, + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + } + ], + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "responses": { + "200": { + "description": "AlertRuleGroupExport", + "schema": { + "$ref": "#/definitions/AlertRuleGroupExport" + } + }, + "404": { + "description": " Not found." + } + }, + "summary": "Export an alert rule group in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/mute-timings": { "get": { "operationId": "RouteGetMuteTimings", @@ -6222,6 +6498,15 @@ ], "description": "Test rule", "operationId": "BacktestConfig", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/BacktestConfig" + } + } + ], "produces": [ "application/json" ], diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 4aa661dc15c..fcae8f39245 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1746,6 +1746,36 @@ } } }, + "/api/v1/provisioning/alert-rules/export": { + "get": { + "tags": [ + "provisioning", + "stable" + ], + "summary": "Export all alert rules in provisioning file format.", + "operationId": "RouteGetAlertRulesExport", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/alert-rules/{UID}": { "get": { "tags": [ @@ -1844,6 +1874,48 @@ } } }, + "/api/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "tags": [ + "provisioning", + "stable" + ], + "summary": "Export an alert rule in provisioning file format.", + "operationId": "RouteGetAlertRuleExport", + "parameters": [ + { + "type": "string", + "description": "Alert rule UID", + "name": "UID", + "in": "path", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/contact-points": { "get": { "tags": [ @@ -2053,6 +2125,53 @@ } } }, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "tags": [ + "provisioning", + "stable" + ], + "summary": "Export an alert rule group in provisioning file format.", + "operationId": "RouteGetAlertRuleGroupExport", + "parameters": [ + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/mute-timings": { "get": { "tags": [ @@ -2397,6 +2516,15 @@ "testing" ], "operationId": "BacktestConfig", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/BacktestConfig" + } + } + ], "responses": { "200": { "description": "BacktestResult", @@ -2603,6 +2731,28 @@ } } }, + "AlertQueryExport": { + "type": "object", + "title": "AlertQueryExport is the provisioned export of models.AlertQuery.", + "properties": { + "datasourceUid": { + "type": "string" + }, + "model": { + "type": "object", + "additionalProperties": {} + }, + "queryType": { + "type": "string" + }, + "refId": { + "type": "string" + }, + "relativeTimeRange": { + "$ref": "#/definitions/RelativeTimeRange" + } + } + }, "AlertResponse": { "type": "object", "required": [ @@ -2623,6 +2773,65 @@ } } }, + "AlertRuleExport": { + "type": "object", + "title": "AlertRuleExport is the provisioned file export of models.AlertRule.", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "condition": { + "type": "string" + }, + "dasboardUid": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertQueryExport" + } + }, + "execErrState": { + "type": "string", + "enum": [ + "Alerting", + "Error", + "OK" + ] + }, + "for": { + "$ref": "#/definitions/Duration" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "noDataState": { + "type": "string", + "enum": [ + "Alerting", + "NoData", + "OK" + ] + }, + "panelId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, "AlertRuleGroup": { "type": "object", "properties": { @@ -2644,6 +2853,31 @@ } } }, + "AlertRuleGroupExport": { + "type": "object", + "title": "AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1.", + "properties": { + "folder": { + "type": "string" + }, + "interval": { + "$ref": "#/definitions/Duration" + }, + "name": { + "type": "string" + }, + "orgId": { + "type": "integer", + "format": "int64" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertRuleExport" + } + } + } + }, "AlertRuleGroupMetadata": { "type": "object", "properties": { @@ -2653,8 +2887,22 @@ } } }, - "AlertStateType": { - "type": "string" + "AlertingFileExport": { + "type": "object", + "title": "AlertingFileExport is the full provisioned file export.", + "properties": { + "apiVersion": { + "type": "integer", + "format": "int64" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertRuleGroupExport" + } + } + }, + "$ref": "#/definitions/AlertingFileExport" }, "AlertingRule": { "description": "adapted from cortex", @@ -2914,9 +3162,31 @@ } }, "DataTopic": { + "description": "nolint:revive", "type": "string", "title": "DataTopic is used to identify which topic the frame should be assigned to." }, + "DiscordConfig": { + "type": "object", + "title": "DiscordConfig configures notifications via Discord.", + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + } + } + }, "DiscoveryBase": { "type": "object", "required": [ @@ -3368,6 +3638,12 @@ "GettableApiReceiver": { "type": "object", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -3426,6 +3702,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -3491,7 +3773,7 @@ } }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -3723,9 +4005,15 @@ "victorops_api_key": { "$ref": "#/definitions/Secret" }, + "victorops_api_key_file": { + "type": "string" + }, "victorops_api_url": { "$ref": "#/definitions/URL" }, + "webex_api_url": { + "$ref": "#/definitions/URL" + }, "wechat_api_corp_id": { "type": "string" }, @@ -3765,6 +4053,9 @@ "oauth2": { "$ref": "#/definitions/OAuth2" }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -3773,6 +4064,15 @@ } } }, + "Header": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/Secret" + } + } + }, "HostPort": { "type": "object", "title": "HostPort represents a \"host:port\" network address.", @@ -3866,82 +4166,6 @@ "$ref": "#/definitions/Label" } }, - "LegacyAlert": { - "type": "object", - "properties": { - "Created": { - "type": "string", - "format": "date-time" - }, - "DashboardId": { - "type": "integer", - "format": "int64" - }, - "EvalData": { - "$ref": "#/definitions/Json" - }, - "ExecutionError": { - "type": "string" - }, - "For": { - "$ref": "#/definitions/Duration" - }, - "Frequency": { - "type": "integer", - "format": "int64" - }, - "Handler": { - "type": "integer", - "format": "int64" - }, - "Id": { - "type": "integer", - "format": "int64" - }, - "Message": { - "type": "string" - }, - "Name": { - "type": "string" - }, - "NewStateDate": { - "type": "string", - "format": "date-time" - }, - "OrgId": { - "type": "integer", - "format": "int64" - }, - "PanelId": { - "type": "integer", - "format": "int64" - }, - "Settings": { - "$ref": "#/definitions/Json" - }, - "Severity": { - "type": "string" - }, - "Silenced": { - "type": "boolean" - }, - "State": { - "$ref": "#/definitions/AlertStateType" - }, - "StateChanges": { - "type": "integer", - "format": "int64" - }, - "Updated": { - "type": "string", - "format": "date-time" - }, - "Version": { - "type": "integer", - "format": "int64" - } - } - }, "MatchRegexps": { "type": "object", "title": "MatchRegexps represents a map of Regexp.", @@ -4240,15 +4464,24 @@ "routing_key": { "$ref": "#/definitions/Secret" }, + "routing_key_file": { + "type": "string" + }, "send_resolved": { "type": "boolean" }, "service_key": { "$ref": "#/definitions/Secret" }, + "service_key_file": { + "type": "string" + }, "severity": { "type": "string" }, + "source": { + "type": "string" + }, "url": { "$ref": "#/definitions/URL" } @@ -4337,6 +4570,12 @@ "PostableApiReceiver": { "type": "object", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -4395,6 +4634,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -4457,7 +4702,7 @@ } }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -4743,6 +4988,9 @@ "token": { "$ref": "#/definitions/Secret" }, + "token_file": { + "type": "string" + }, "url": { "type": "string" }, @@ -4751,6 +4999,9 @@ }, "user_key": { "$ref": "#/definitions/Secret" + }, + "user_key_file": { + "type": "string" } } }, @@ -4835,10 +5086,19 @@ } } }, + "RawMessage": { + "type": "object" + }, "Receiver": { "type": "object", "title": "Receiver configuration provides configuration on how to contact a receiver.", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -4891,6 +5151,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -5392,6 +5658,9 @@ "description": "The client key file for the targets.", "type": "string" }, + "max_version": { + "$ref": "#/definitions/TLSVersion" + }, "min_version": { "$ref": "#/definitions/TLSVersion" }, @@ -5699,7 +5968,7 @@ "$ref": "#/definitions/Secret" }, "api_key_file": { - "$ref": "#/definitions/Secret" + "type": "string" }, "api_url": { "$ref": "#/definitions/URL" @@ -5737,6 +6006,27 @@ "type": "string", "title": "VisType is used to indicate how the data should be visualized in explore." }, + "WebexConfig": { + "type": "object", + "title": "WebexConfig configures notifications via Webex.", + "properties": { + "api_url": { + "$ref": "#/definitions/URL" + }, + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "room_id": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + } + } + }, "WebhookConfig": { "type": "object", "title": "WebhookConfig configures notifications via a generic webhook.", @@ -5814,6 +6104,7 @@ } }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -5943,7 +6234,6 @@ } }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -6007,6 +6297,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -6063,7 +6354,6 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { - "description": "Integration integration", "type": "object", "required": [ "name", diff --git a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache index 34dfce4f1ad..7a7f7498108 100644 --- a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache +++ b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache @@ -11,14 +11,15 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/middleware" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) type {{classname}} interface { {{#operation}} - {{nickname}}(*models.ReqContext) response.Response{{/operation}} + {{nickname}}(*contextmodel.ReqContext) response.Response{{/operation}} } {{#operations}}{{#operation}} -func (f *{{classname}}Handler) {{nickname}}(ctx *models.ReqContext) response.Response { {{#hasPathParams}} +func (f *{{classname}}Handler) {{nickname}}(ctx *contextmodel.ReqContext) response.Response { {{#hasPathParams}} // Parse Path Parameters{{/hasPathParams}}{{#pathParams}} {{paramName}}Param := web.Params(ctx.Req)[":{{baseName}}"]{{/pathParams}} {{#bodyParams}} diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index 3a101965855..cdb5c170216 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -15,8 +15,8 @@ import ( "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -34,7 +34,7 @@ func toMacaronPath(path string) string { })) } -func getDatasourceByUID(ctx *models.ReqContext, cache datasources.CacheService, expectedType apimodels.Backend) (*datasources.DataSource, error) { +func getDatasourceByUID(ctx *contextmodel.ReqContext, cache datasources.CacheService, expectedType apimodels.Backend) (*datasources.DataSource, error) { datasourceUID := web.Params(ctx.Req)[":DatasourceUID"] ds, err := cache.GetDatasourceByUID(ctx.Req.Context(), datasourceUID, ctx.SignedInUser, ctx.SkipCache) if err != nil { @@ -69,12 +69,12 @@ func (w *safeMacaronWrapper) CloseNotify() <-chan bool { // createProxyContext creates a new request context that is provided down to the data source proxy. // The request context -// 1. overwrites the underlying response writer used by a *models.ReqContext because AlertingProxy needs to intercept +// 1. overwrites the underlying response writer used by a *contextmodel.ReqContext because AlertingProxy needs to intercept // the response from the data source to analyze it and probably change // 2. elevates the current user permissions to Editor if both conditions are met: RBAC is enabled, user does not have Editor role. // This is needed to bypass the plugin authorization, which still relies on the legacy roles. // This elevation can be considered safe because all upstream calls are protected by the RBAC on web request router level. -func (p *AlertingProxy) createProxyContext(ctx *models.ReqContext, request *http.Request, response *response.NormalResponse) *models.ReqContext { +func (p *AlertingProxy) createProxyContext(ctx *contextmodel.ReqContext, request *http.Request, response *response.NormalResponse) *contextmodel.ReqContext { cpy := *ctx cpyMCtx := *cpy.Context cpyMCtx.Resp = web.NewResponseWriter(ctx.Req.Method, &safeMacaronWrapper{response}) @@ -100,7 +100,7 @@ type AlertingProxy struct { // withReq proxies a different request func (p *AlertingProxy) withReq( - ctx *models.ReqContext, + ctx *contextmodel.ReqContext, method string, u *url.URL, body io.Reader, diff --git a/pkg/services/ngalert/api/util_test.go b/pkg/services/ngalert/api/util_test.go index ce79d0fed9f..6c5d5e18980 100644 --- a/pkg/services/ngalert/api/util_test.go +++ b/pkg/services/ngalert/api/util_test.go @@ -10,9 +10,9 @@ import ( "github.com/grafana/grafana/pkg/api/response" "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" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" models2 "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -41,7 +41,7 @@ func TestToMacaronPath(t *testing.T) { } func TestAlertingProxy_createProxyContext(t *testing.T) { - ctx := &models.ReqContext{ + ctx := &contextmodel.ReqContext{ Context: &web.Context{ Req: &http.Request{}, }, diff --git a/pkg/services/ngalert/metrics/alertmanager.go b/pkg/services/ngalert/metrics/alertmanager.go new file mode 100644 index 00000000000..23b85e20723 --- /dev/null +++ b/pkg/services/ngalert/metrics/alertmanager.go @@ -0,0 +1,21 @@ +package metrics + +import ( + "fmt" + + "github.com/prometheus/alertmanager/api/metrics" + "github.com/prometheus/client_golang/prometheus" +) + +type Alertmanager struct { + Registerer prometheus.Registerer + *metrics.Alerts +} + +// NewAlertmanagerMetrics creates a set of metrics for the Alertmanager of each organization. +func NewAlertmanagerMetrics(r prometheus.Registerer) *Alertmanager { + return &Alertmanager{ + Registerer: r, + Alerts: metrics.NewAlerts("grafana", prometheus.WrapRegistererWithPrefix(fmt.Sprintf("%s_%s_", Namespace, Subsystem), r)), + } +} diff --git a/pkg/services/ngalert/metrics/api.go b/pkg/services/ngalert/metrics/api.go new file mode 100644 index 00000000000..db97033a0b5 --- /dev/null +++ b/pkg/services/ngalert/metrics/api.go @@ -0,0 +1,25 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type API struct { + RequestDuration *prometheus.HistogramVec +} + +func NewAPIMetrics(r prometheus.Registerer) *API { + return &API{ + RequestDuration: promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "request_duration_seconds", + Help: "Histogram of requests to the Alerting API", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "route", "status_code", "backend"}, + ), + } +} diff --git a/pkg/services/ngalert/metrics/multi_org_alertmanager.go b/pkg/services/ngalert/metrics/multi_org_alertmanager.go new file mode 100644 index 00000000000..c755457755d --- /dev/null +++ b/pkg/services/ngalert/metrics/multi_org_alertmanager.go @@ -0,0 +1,32 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type MultiOrgAlertmanager struct { + Registerer prometheus.Registerer + ActiveConfigurations prometheus.Gauge + DiscoveredConfigurations prometheus.Gauge + registries *OrgRegistries +} + +func NewMultiOrgAlertmanagerMetrics(r prometheus.Registerer) *MultiOrgAlertmanager { + return &MultiOrgAlertmanager{ + Registerer: r, + registries: NewOrgRegistries(), + DiscoveredConfigurations: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "discovered_configurations", + Help: "The number of organizations we've discovered that require an Alertmanager configuration.", + }), + ActiveConfigurations: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "active_configurations", + Help: "The number of active Alertmanager configurations.", + }), + } +} diff --git a/pkg/services/ngalert/metrics/ngalert.go b/pkg/services/ngalert/metrics/ngalert.go index 035ed3a3992..ae1a304ed34 100644 --- a/pkg/services/ngalert/metrics/ngalert.go +++ b/pkg/services/ngalert/metrics/ngalert.go @@ -1,22 +1,7 @@ package metrics import ( - "fmt" - "regexp" - "strings" - "sync" - "time" - - "github.com/prometheus/alertmanager/api/metrics" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - - "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - "github.com/grafana/grafana/pkg/util/ticker" - - "github.com/grafana/grafana/pkg/web" ) const ( @@ -37,47 +22,24 @@ func ProvideServiceForTest() *NGAlert { } type NGAlert struct { - // Registerer is for use by subcomponents which register their own metrics. - Registerer prometheus.Registerer + // Registerer is used by subcomponents which register their own metrics. + Registerer prometheus.Registerer + schedulerMetrics *Scheduler stateMetrics *State multiOrgAlertmanagerMetrics *MultiOrgAlertmanager apiMetrics *API } -type Scheduler struct { - Registerer prometheus.Registerer - BehindSeconds prometheus.Gauge - EvalTotal *prometheus.CounterVec - EvalFailures *prometheus.CounterVec - EvalDuration *prometheus.HistogramVec - SchedulePeriodicDuration prometheus.Histogram - SchedulableAlertRules prometheus.Gauge - SchedulableAlertRulesHash prometheus.Gauge - UpdateSchedulableAlertRulesDuration prometheus.Histogram - Ticker *ticker.Metrics - EvaluationMissed *prometheus.CounterVec -} - -type MultiOrgAlertmanager struct { - Registerer prometheus.Registerer - ActiveConfigurations prometheus.Gauge - DiscoveredConfigurations prometheus.Gauge - registries *OrgRegistries -} - -type API struct { - RequestDuration *prometheus.HistogramVec -} - -type Alertmanager struct { - Registerer prometheus.Registerer - *metrics.Alerts -} - -type State struct { - GroupRules *prometheus.GaugeVec - AlertState *prometheus.GaugeVec +// NewNGAlert manages the metrics of all the alerting components. +func NewNGAlert(r prometheus.Registerer) *NGAlert { + return &NGAlert{ + Registerer: r, + schedulerMetrics: NewSchedulerMetrics(r), + stateMetrics: NewStateMetrics(r), + multiOrgAlertmanagerMetrics: NewMultiOrgAlertmanagerMetrics(r), + apiMetrics: NewAPIMetrics(r), + } } func (ng *NGAlert) GetSchedulerMetrics() *Scheduler { @@ -96,25 +58,6 @@ func (ng *NGAlert) GetMultiOrgAlertmanagerMetrics() *MultiOrgAlertmanager { return ng.multiOrgAlertmanagerMetrics } -// NewNGAlert manages the metrics of all the alerting components. -func NewNGAlert(r prometheus.Registerer) *NGAlert { - return &NGAlert{ - Registerer: r, - schedulerMetrics: NewSchedulerMetrics(r), - stateMetrics: newStateMetrics(r), - multiOrgAlertmanagerMetrics: newMultiOrgAlertmanagerMetrics(r), - apiMetrics: newAPIMetrics(r), - } -} - -// NewAlertmanagerMetrics creates a set of metrics for the Alertmanager of each organization. -func NewAlertmanagerMetrics(r prometheus.Registerer) *Alertmanager { - return &Alertmanager{ - Registerer: r, - Alerts: metrics.NewAlerts("grafana", prometheus.WrapRegistererWithPrefix(fmt.Sprintf("%s_%s_", Namespace, Subsystem), r)), - } -} - // RemoveOrgRegistry removes the *prometheus.Registry for the specified org. It is safe to call concurrently. func (moa *MultiOrgAlertmanager) RemoveOrgRegistry(id int64) { moa.registries.RemoveOrgRegistry(id) @@ -124,232 +67,3 @@ func (moa *MultiOrgAlertmanager) RemoveOrgRegistry(id int64) { func (moa *MultiOrgAlertmanager) GetOrCreateOrgRegistry(id int64) prometheus.Registerer { return moa.registries.GetOrCreateOrgRegistry(id) } - -func NewSchedulerMetrics(r prometheus.Registerer) *Scheduler { - return &Scheduler{ - Registerer: r, - BehindSeconds: promauto.With(r).NewGauge(prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "scheduler_behind_seconds", - Help: "The total number of seconds the scheduler is behind.", - }), - // TODO: once rule groups support multiple rules, consider partitioning - // on rule group as well as tenant, similar to loki|cortex. - EvalTotal: promauto.With(r).NewCounterVec( - prometheus.CounterOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "rule_evaluations_total", - Help: "The total number of rule evaluations.", - }, - []string{"org"}, - ), - // TODO: once rule groups support multiple rules, consider partitioning - // on rule group as well as tenant, similar to loki|cortex. - EvalFailures: promauto.With(r).NewCounterVec( - prometheus.CounterOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "rule_evaluation_failures_total", - Help: "The total number of rule evaluation failures.", - }, - []string{"org"}, - ), - EvalDuration: promauto.With(r).NewHistogramVec( - prometheus.HistogramOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "rule_evaluation_duration_seconds", - Help: "The duration for a rule to execute.", - Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, - }, - []string{"org"}, - ), - SchedulePeriodicDuration: promauto.With(r).NewHistogram( - prometheus.HistogramOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "schedule_periodic_duration_seconds", - Help: "The time taken to run the scheduler.", - Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10}, - }, - ), - SchedulableAlertRules: promauto.With(r).NewGauge( - prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "schedule_alert_rules", - Help: "The number of alert rules that could be considered for evaluation at the next tick.", - }, - ), - SchedulableAlertRulesHash: promauto.With(r).NewGauge( - prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "schedule_alert_rules_hash", - Help: "A hash of the alert rules that could be considered for evaluation at the next tick.", - }), - UpdateSchedulableAlertRulesDuration: promauto.With(r).NewHistogram( - prometheus.HistogramOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "schedule_query_alert_rules_duration_seconds", - Help: "The time taken to fetch alert rules from the database.", - Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10}, - }, - ), - Ticker: ticker.NewMetrics(r, "alerting"), - EvaluationMissed: promauto.With(r).NewCounterVec( - prometheus.CounterOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "schedule_rule_evaluations_missed_total", - Help: "The total number of rule evaluations missed due to a slow rule evaluation.", - }, - []string{"org", "name"}, - ), - } -} - -func newStateMetrics(r prometheus.Registerer) *State { - return &State{ - // TODO: once rule groups support multiple rules, consider partitioning - // on rule group as well as tenant, similar to loki|cortex. - GroupRules: promauto.With(r).NewGaugeVec( - prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "rule_group_rules", - Help: "The number of rules.", - }, - []string{"org"}, - ), - AlertState: promauto.With(r).NewGaugeVec(prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "alerts", - Help: "How many alerts by state.", - }, []string{"state"}), - } -} - -func newMultiOrgAlertmanagerMetrics(r prometheus.Registerer) *MultiOrgAlertmanager { - return &MultiOrgAlertmanager{ - Registerer: r, - registries: NewOrgRegistries(), - DiscoveredConfigurations: promauto.With(r).NewGauge(prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "discovered_configurations", - Help: "The number of organizations we've discovered that require an Alertmanager configuration.", - }), - ActiveConfigurations: promauto.With(r).NewGauge(prometheus.GaugeOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "active_configurations", - Help: "The number of active Alertmanager configurations.", - }), - } -} - -func newAPIMetrics(r prometheus.Registerer) *API { - return &API{ - RequestDuration: promauto.With(r).NewHistogramVec( - prometheus.HistogramOpts{ - Namespace: Namespace, - Subsystem: Subsystem, - Name: "request_duration_seconds", - Help: "Histogram of requests to the Alerting API", - Buckets: prometheus.DefBuckets, - }, - []string{"method", "route", "status_code", "backend"}, - ), - } -} - -// OrgRegistries represents a map of registries per org. -type OrgRegistries struct { - regsMu sync.Mutex - regs map[int64]prometheus.Registerer -} - -func NewOrgRegistries() *OrgRegistries { - return &OrgRegistries{ - regs: make(map[int64]prometheus.Registerer), - } -} - -// GetOrCreateOrgRegistry gets or creates a *prometheus.Registry for the specified org. It is safe to call concurrently. -func (m *OrgRegistries) GetOrCreateOrgRegistry(orgID int64) prometheus.Registerer { - m.regsMu.Lock() - defer m.regsMu.Unlock() - - orgRegistry, ok := m.regs[orgID] - if !ok { - reg := prometheus.NewRegistry() - m.regs[orgID] = reg - return reg - } - return orgRegistry -} - -// RemoveOrgRegistry removes the *prometheus.Registry for the specified org. It is safe to call concurrently. -func (m *OrgRegistries) RemoveOrgRegistry(org int64) { - m.regsMu.Lock() - defer m.regsMu.Unlock() - delete(m.regs, org) -} - -// Instrument wraps a middleware, instrumenting the request latencies. -func Instrument( - method, - path string, - action func(*models.ReqContext) response.Response, - metrics *API, -) web.Handler { - normalizedPath := MakeLabelValue(path) - - return func(c *models.ReqContext) { - start := time.Now() - res := action(c) - - // TODO: We could look up the datasource type via our datasource service - var backend string - datasourceID := web.Params(c.Req)[":DatasourceID"] - if datasourceID == apimodels.GrafanaBackend.String() || datasourceID == "" { - backend = GrafanaBackend - } else { - backend = ProxyBackend - } - - ls := prometheus.Labels{ - "method": method, - "route": normalizedPath, - "status_code": fmt.Sprint(res.Status()), - "backend": backend, - } - res.WriteTo(c) - metrics.RequestDuration.With(ls).Observe(time.Since(start).Seconds()) - } -} - -var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) - -// MakeLabelValue normalizes a path template -func MakeLabelValue(path string) string { - // Convert non-alnums to underscores. - result := invalidChars.ReplaceAllString(path, "_") - - // Trim leading and trailing underscores. - result = strings.Trim(result, "_") - - // Make it all lowercase - result = strings.ToLower(result) - - // Special case. - if result == "" { - result = "root" - } - return result -} diff --git a/pkg/services/ngalert/metrics/scheduler.go b/pkg/services/ngalert/metrics/scheduler.go new file mode 100644 index 00000000000..fb1523918a3 --- /dev/null +++ b/pkg/services/ngalert/metrics/scheduler.go @@ -0,0 +1,108 @@ +package metrics + +import ( + "github.com/grafana/grafana/pkg/util/ticker" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type Scheduler struct { + Registerer prometheus.Registerer + BehindSeconds prometheus.Gauge + EvalTotal *prometheus.CounterVec + EvalFailures *prometheus.CounterVec + EvalDuration *prometheus.HistogramVec + SchedulePeriodicDuration prometheus.Histogram + SchedulableAlertRules prometheus.Gauge + SchedulableAlertRulesHash prometheus.Gauge + UpdateSchedulableAlertRulesDuration prometheus.Histogram + Ticker *ticker.Metrics + EvaluationMissed *prometheus.CounterVec +} + +func NewSchedulerMetrics(r prometheus.Registerer) *Scheduler { + return &Scheduler{ + Registerer: r, + BehindSeconds: promauto.With(r).NewGauge(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "scheduler_behind_seconds", + Help: "The total number of seconds the scheduler is behind.", + }), + // TODO: once rule groups support multiple rules, consider partitioning + // on rule group as well as tenant, similar to loki|cortex. + EvalTotal: promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "rule_evaluations_total", + Help: "The total number of rule evaluations.", + }, + []string{"org"}, + ), + // TODO: once rule groups support multiple rules, consider partitioning + // on rule group as well as tenant, similar to loki|cortex. + EvalFailures: promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "rule_evaluation_failures_total", + Help: "The total number of rule evaluation failures.", + }, + []string{"org"}, + ), + EvalDuration: promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "rule_evaluation_duration_seconds", + Help: "The duration for a rule to execute.", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, + }, + []string{"org"}, + ), + SchedulePeriodicDuration: promauto.With(r).NewHistogram( + prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "schedule_periodic_duration_seconds", + Help: "The time taken to run the scheduler.", + Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10}, + }, + ), + SchedulableAlertRules: promauto.With(r).NewGauge( + prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "schedule_alert_rules", + Help: "The number of alert rules that could be considered for evaluation at the next tick.", + }, + ), + SchedulableAlertRulesHash: promauto.With(r).NewGauge( + prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "schedule_alert_rules_hash", + Help: "A hash of the alert rules that could be considered for evaluation at the next tick.", + }), + UpdateSchedulableAlertRulesDuration: promauto.With(r).NewHistogram( + prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "schedule_query_alert_rules_duration_seconds", + Help: "The time taken to fetch alert rules from the database.", + Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10}, + }, + ), + Ticker: ticker.NewMetrics(r, "alerting"), + EvaluationMissed: promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "schedule_rule_evaluations_missed_total", + Help: "The total number of rule evaluations missed due to a slow rule evaluation.", + }, + []string{"org", "name"}, + ), + } +} diff --git a/pkg/services/ngalert/metrics/state.go b/pkg/services/ngalert/metrics/state.go new file mode 100644 index 00000000000..2a5bf072b1a --- /dev/null +++ b/pkg/services/ngalert/metrics/state.go @@ -0,0 +1,33 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type State struct { + GroupRules *prometheus.GaugeVec + AlertState *prometheus.GaugeVec +} + +func NewStateMetrics(r prometheus.Registerer) *State { + return &State{ + // TODO: once rule groups support multiple rules, consider partitioning + // on rule group as well as tenant, similar to loki|cortex. + GroupRules: promauto.With(r).NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "rule_group_rules", + Help: "The number of rules.", + }, + []string{"org"}, + ), + AlertState: promauto.With(r).NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "alerts", + Help: "How many alerts by state.", + }, []string{"state"}), + } +} diff --git a/pkg/services/ngalert/metrics/util.go b/pkg/services/ngalert/metrics/util.go new file mode 100644 index 00000000000..db65e1e2b87 --- /dev/null +++ b/pkg/services/ngalert/metrics/util.go @@ -0,0 +1,104 @@ +package metrics + +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" + + "github.com/grafana/grafana/pkg/web" + + "github.com/grafana/grafana/pkg/api/response" + + "github.com/prometheus/client_golang/prometheus" + + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +// OrgRegistries represents a map of registries per org. +type OrgRegistries struct { + regsMu sync.Mutex + regs map[int64]prometheus.Registerer +} + +func NewOrgRegistries() *OrgRegistries { + return &OrgRegistries{ + regs: make(map[int64]prometheus.Registerer), + } +} + +// GetOrCreateOrgRegistry gets or creates a *prometheus.Registry for the specified org. It is safe to call concurrently. +func (m *OrgRegistries) GetOrCreateOrgRegistry(orgID int64) prometheus.Registerer { + m.regsMu.Lock() + defer m.regsMu.Unlock() + + orgRegistry, ok := m.regs[orgID] + if !ok { + reg := prometheus.NewRegistry() + m.regs[orgID] = reg + return reg + } + return orgRegistry +} + +// RemoveOrgRegistry removes the *prometheus.Registry for the specified org. It is safe to call concurrently. +func (m *OrgRegistries) RemoveOrgRegistry(org int64) { + m.regsMu.Lock() + defer m.regsMu.Unlock() + delete(m.regs, org) +} + +// Instrument wraps a middleware, instrumenting the request latencies. +func Instrument( + method, + path string, + action func(*contextmodel.ReqContext) response.Response, + metrics *API, +) web.Handler { + normalizedPath := MakeLabelValue(path) + + return func(c *contextmodel.ReqContext) { + start := time.Now() + res := action(c) + + // TODO: We could look up the datasource type via our datasource service + var backend string + datasourceID := web.Params(c.Req)[":DatasourceID"] + if datasourceID == apimodels.GrafanaBackend.String() || datasourceID == "" { + backend = GrafanaBackend + } else { + backend = ProxyBackend + } + + ls := prometheus.Labels{ + "method": method, + "route": normalizedPath, + "status_code": fmt.Sprint(res.Status()), + "backend": backend, + } + res.WriteTo(c) + metrics.RequestDuration.With(ls).Observe(time.Since(start).Seconds()) + } +} + +var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +// MakeLabelValue normalizes a path template +func MakeLabelValue(path string) string { + // Convert non-alnums to underscores. + result := invalidChars.ReplaceAllString(path, "_") + + // Trim leading and trailing underscores. + result = strings.Trim(result, "_") + + // Make it all lowercase + result = strings.ToLower(result) + + // Special case. + if result == "" { + result = "root" + } + return result +} diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 884b59516fa..f7d58293b26 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" alertingModels "github.com/grafana/alerting/alerting/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -109,6 +110,9 @@ const ( const ( StateReasonMissingSeries = "MissingSeries" StateReasonError = "Error" + StateReasonPaused = "Paused" + StateReasonUpdated = "Updated" + StateReasonRuleDeleted = "RuleDeleted" ) var ( @@ -156,6 +160,7 @@ type AlertRule struct { For time.Duration Annotations map[string]string Labels map[string]string + IsPaused bool } // GetDashboardUID returns the DashboardUID or "". @@ -261,6 +266,11 @@ type AlertRuleKeyWithVersion struct { AlertRuleKey `xorm:"extends"` } +type AlertRuleKeyWithVersionAndPauseStatus struct { + IsPaused bool + AlertRuleKeyWithVersion `xorm:"extends"` +} + // AlertRuleGroupKey is the identifier of a group of alerts type AlertRuleGroupKey struct { OrgID int64 @@ -335,6 +345,7 @@ type AlertRuleVersion struct { For time.Duration Annotations map[string]string Labels map[string]string + IsPaused bool } // GetAlertRuleByUIDQuery is the query for retrieving/deleting an alert rule by UID and organisation ID. diff --git a/pkg/services/ngalert/models/instance.go b/pkg/services/ngalert/models/instance.go index 7a62fcb1fce..e4180109772 100644 --- a/pkg/services/ngalert/models/instance.go +++ b/pkg/services/ngalert/models/instance.go @@ -34,7 +34,7 @@ const ( InstanceStatePending InstanceStateType = "Pending" // InstanceStateNoData is for an alert with no data. InstanceStateNoData InstanceStateType = "NoData" - // InstanceStateError is for a erroring alert. + // InstanceStateError is for an erroring alert. InstanceStateError InstanceStateType = "Error" ) diff --git a/pkg/services/ngalert/models/instance_test.go b/pkg/services/ngalert/models/instance_test.go new file mode 100644 index 00000000000..82263b1bcd8 --- /dev/null +++ b/pkg/services/ngalert/models/instance_test.go @@ -0,0 +1,105 @@ +package models + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInstanceStateType_IsValid(t *testing.T) { + testCases := []struct { + instanceType InstanceStateType + expectedValidity bool + }{ + { + instanceType: InstanceStateFiring, + expectedValidity: true, + }, + { + instanceType: InstanceStateNormal, + expectedValidity: true, + }, + { + instanceType: InstanceStatePending, + expectedValidity: true, + }, + { + instanceType: InstanceStateNoData, + expectedValidity: true, + }, + { + instanceType: InstanceStateError, + expectedValidity: true, + }, + { + instanceType: InstanceStateType("notAValidInstanceStateType"), + expectedValidity: false, + }, + } + + for _, tc := range testCases { + t.Run(buildTestInstanceStateTypeIsValidName(tc.instanceType, tc.expectedValidity), func(t *testing.T) { + require.Equal(t, tc.expectedValidity, tc.instanceType.IsValid()) + }) + } +} + +func buildTestInstanceStateTypeIsValidName(instanceType InstanceStateType, expectedValidity bool) string { + if expectedValidity { + return fmt.Sprintf("%q should be valid", instanceType) + } + return fmt.Sprintf("%q should not be valid", instanceType) +} + +func TestValidateAlertInstance(t *testing.T) { + testCases := []struct { + name string + orgId int64 + uid string + currentState InstanceStateType + err error + }{ + { + name: "fails if orgID is empty", + orgId: 0, + uid: "validUid", + currentState: InstanceStateNormal, + err: errors.New("alert instance is invalid due to missing alert rule organisation"), + }, + { + name: "fails if uid is empty", + orgId: 1, + uid: "", + currentState: InstanceStateNormal, + err: errors.New("alert instance is invalid due to missing alert rule uid"), + }, + { + name: "fails if current state is not valid", + orgId: 1, + uid: "validUid", + currentState: InstanceStateType("notAValidType"), + err: errors.New("alert instance is invalid because the state 'notAValidType' is invalid"), + }, + { + name: "ok if validated fields are correct", + orgId: 1, + uid: "validUid", + currentState: InstanceStateNormal, + err: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + instance := AlertInstanceGen(func(instance *AlertInstance) { + instance.AlertInstanceKey.RuleOrgID = tc.orgId + instance.AlertInstanceKey.RuleUID = tc.uid + instance.CurrentState = tc.currentState + }) + + require.Equal(t, tc.err, ValidateAlertInstance(*instance)) + }) + } +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 6c1ed0c7897..544c4cb8330 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -337,3 +337,46 @@ func CreateClassicConditionExpression(refID string, inputRefID string, reducer s }`, refID, inputRefID, operation, threshold, reducer, expr.OldDatasourceUID, expr.DatasourceType)), } } + +type AlertInstanceMutator func(*AlertInstance) + +// AlertInstanceGen provides a factory function that generates a random AlertInstance. +// The mutators arguments allows changing fields of the resulting structure. +func AlertInstanceGen(mutators ...AlertInstanceMutator) *AlertInstance { + var labels map[string]string = nil + if rand.Int63()%2 == 0 { + labels = GenerateAlertLabels(rand.Intn(5), "lbl-") + } + + randState := func() InstanceStateType { + s := [...]InstanceStateType{ + InstanceStateFiring, + InstanceStateNormal, + InstanceStatePending, + InstanceStateNoData, + InstanceStateError, + } + return s[rand.Intn(len(s))] + } + + currentStateSince := time.Now().Add(-time.Duration(rand.Intn(100) + 1)) + + instance := &AlertInstance{ + AlertInstanceKey: AlertInstanceKey{ + RuleOrgID: rand.Int63n(1500), + RuleUID: util.GenerateShortUID(), + LabelsHash: util.GenerateShortUID(), + }, + Labels: labels, + CurrentState: randState(), + CurrentReason: "TEST-REASON-" + util.GenerateShortUID(), + CurrentStateSince: currentStateSince, + CurrentStateEnd: currentStateSince.Add(time.Duration(rand.Intn(100) + 200)), + LastEvalTime: time.Now().Add(-time.Duration(rand.Intn(100) + 50)), + } + + for _, mutator := range mutators { + mutator(instance) + } + return instance +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index a5a9fa894db..3908c308cd0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "time" "github.com/benbjohnson/clock" "golang.org/x/sync/errgroup" @@ -143,6 +144,10 @@ type AlertNG struct { func (ng *AlertNG) init() error { var err error + // AlertNG should be initialized before the cancellation deadline of initCtx + initCtx, cancelFunc := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelFunc() + store := &store.DBstore{ Cfg: ng.Cfg.UnifiedAlerting, FeatureToggles: ng.FeatureToggles, @@ -168,7 +173,7 @@ func (ng *AlertNG) init() error { ng.imageService = imageService // Let's make sure we're able to complete an initial sync of Alertmanagers before we start the alerting components. - if err := ng.MultiOrgAlertmanager.LoadAndSyncAlertmanagersForOrgs(context.Background()); err != nil { + if err := ng.MultiOrgAlertmanager.LoadAndSyncAlertmanagersForOrgs(initCtx); err != nil { return fmt.Errorf("failed to initialize alerting because multiorg alertmanager manager failed to warm up: %w", err) } @@ -205,7 +210,7 @@ func (ng *AlertNG) init() error { Tracer: ng.tracer, } - history, err := configureHistorianBackend(ng.Cfg.UnifiedAlerting.StateHistory, ng.annotationsRepo, ng.dashboardService, ng.store) + history, err := configureHistorianBackend(initCtx, ng.Cfg.UnifiedAlerting.StateHistory, ng.annotationsRepo, ng.dashboardService, ng.store) if err != nil { return err } @@ -223,7 +228,7 @@ func (ng *AlertNG) init() error { // if it is required to include folder title to the alerts, we need to subscribe to changes of alert title if !ng.Cfg.UnifiedAlerting.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel) { - subscribeToFolderChanges(ng.Log, ng.bus, store, scheduler) + subscribeToFolderChanges(context.Background(), ng.Log, ng.bus, store, scheduler) } ng.stateManager = stateManager @@ -234,7 +239,7 @@ func (ng *AlertNG) init() error { contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log) templateService := provisioning.NewTemplateService(store, store, store, ng.Log) muteTimingService := provisioning.NewMuteTimingService(store, store, store, ng.Log) - alertRuleService := provisioning.NewAlertRuleService(store, store, ng.QuotaService, store, + alertRuleService := provisioning.NewAlertRuleService(store, store, ng.dashboardService, ng.QuotaService, store, int64(ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()), ng.Log) @@ -290,14 +295,14 @@ func (ng *AlertNG) init() error { return DeclareFixedRoles(ng.accesscontrolService) } -func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleStore, scheduler schedule.ScheduleService) { +func subscribeToFolderChanges(ctx context.Context, logger log.Logger, bus bus.Bus, dbStore api.RuleStore, scheduler schedule.ScheduleService) { // if folder title is changed, we update all alert rules in that folder to make sure that all peers (in HA mode) will update folder title and // clean up the current state bus.AddEventListener(func(ctx context.Context, e *events.FolderTitleUpdated) error { // do not block the upstream execution go func(evt *events.FolderTitleUpdated) { logger.Info("Got folder title updated event. updating rules in the folder", "folderUID", evt.UID) - updated, err := dbStore.IncreaseVersionForAllRulesInNamespace(context.Background(), evt.OrgID, evt.UID) + updated, err := dbStore.IncreaseVersionForAllRulesInNamespace(ctx, evt.OrgID, evt.UID) if err != nil { logger.Error("Failed to update alert rules in the folder after its title was changed", "error", err, "folderUID", evt.UID, "folder", evt.Title) return @@ -305,7 +310,7 @@ func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleSt if len(updated) > 0 { logger.Info("Rules that belong to the folder have been updated successfully. Clearing their status", "folderUID", evt.UID, "updatedRules", len(updated)) for _, key := range updated { - scheduler.UpdateAlertRule(key.AlertRuleKey, key.Version) + scheduler.UpdateAlertRule(key.AlertRuleKey, key.Version, key.IsPaused) } } else { logger.Debug("No alert rules found in the folder. nothing to update", "folderUID", evt.UID, "folder", evt.Title) @@ -378,7 +383,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, nil } -func configureHistorianBackend(cfg setting.UnifiedAlertingStateHistorySettings, ar annotations.Repository, ds dashboards.DashboardService, rs historian.RuleStore) (state.Historian, error) { +func configureHistorianBackend(ctx context.Context, cfg setting.UnifiedAlertingStateHistorySettings, ar annotations.Repository, ds dashboards.DashboardService, rs historian.RuleStore) (state.Historian, error) { if !cfg.Enabled { return historian.NewNopHistorian(), nil } @@ -397,7 +402,9 @@ func configureHistorianBackend(cfg setting.UnifiedAlertingStateHistorySettings, BasicAuthPassword: cfg.LokiBasicAuthPassword, TenantID: cfg.LokiTenantID, }) - if err := backend.TestConnection(); err != nil { + testConnCtx, cancelFunc := context.WithTimeout(ctx, 10*time.Second) + defer cancelFunc() + if err := backend.TestConnection(testConnCtx); err != nil { return nil, fmt.Errorf("failed to ping the remote loki historian: %w", err) } return backend, nil diff --git a/pkg/services/ngalert/ngalert_test.go b/pkg/services/ngalert/ngalert_test.go index 2d70e25c5ce..a8b7d6c6f39 100644 --- a/pkg/services/ngalert/ngalert_test.go +++ b/pkg/services/ngalert/ngalert_test.go @@ -35,9 +35,9 @@ func Test_subscribeToFolderChanges(t *testing.T) { db.PutRule(context.Background(), rules...) scheduler := &schedule.FakeScheduleService{} - scheduler.On("UpdateAlertRule", mock.Anything, mock.Anything).Return() + scheduler.On("UpdateAlertRule", mock.Anything, mock.Anything, mock.Anything).Return() - subscribeToFolderChanges(log.New("test"), bus, db, scheduler) + subscribeToFolderChanges(context.Background(), log.New("test"), bus, db, scheduler) err := bus.Publish(context.Background(), &events.FolderTitleUpdated{ Timestamp: time.Now(), @@ -69,6 +69,6 @@ func Test_subscribeToFolderChanges(t *testing.T) { }, time.Second, 10*time.Millisecond, "scheduler was expected to be called %d times but called %d", len(rules), calledTimes) for _, rule := range rules { - scheduler.AssertCalled(t, "UpdateAlertRule", rule.GetKey(), rule.Version) + scheduler.AssertCalled(t, "UpdateAlertRule", rule.GetKey(), rule.Version, false) } } diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go index 02478c589bd..2a964b1e5d0 100644 --- a/pkg/services/ngalert/notifier/receivers.go +++ b/pkg/services/ngalert/notifier/receivers.go @@ -60,7 +60,7 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei for _, r := range c.Receivers { greceivers := make([]*alerting.GrafanaReceiver, 0, len(r.GrafanaManagedReceivers)) for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - var settings map[string]string + var settings map[string]interface{} //TODO: We shouldn't need to do this marshalling. j, err := gr.Settings.MarshalJSON() if err != nil { diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 27d6b531946..18b2ba3f361 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "sort" "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/provisioning/alerting/file" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util" ) @@ -18,6 +21,7 @@ type AlertRuleService struct { baseIntervalSeconds int64 ruleStore RuleStore provenanceStore ProvisioningStore + dashboardService dashboards.DashboardService quotas QuotaChecker xact TransactionManager log log.Logger @@ -25,6 +29,7 @@ type AlertRuleService struct { func NewAlertRuleService(ruleStore RuleStore, provenanceStore ProvisioningStore, + dashboardService dashboards.DashboardService, quotas QuotaChecker, xact TransactionManager, defaultIntervalSeconds int64, @@ -35,6 +40,7 @@ func NewAlertRuleService(ruleStore RuleStore, baseIntervalSeconds: baseIntervalSeconds, ruleStore: ruleStore, provenanceStore: provenanceStore, + dashboardService: dashboardService, quotas: quotas, xact: xact, log: log, @@ -69,6 +75,38 @@ func (service *AlertRuleService) GetAlertRule(ctx context.Context, orgID int64, return *query.Result, provenance, nil } +type AlertRuleWithFolderTitle struct { + AlertRule models.AlertRule + FolderTitle string +} + +// GetAlertRuleWithFolderTitle returns a single alert rule with its folder title. +func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context, orgID int64, ruleUID string) (AlertRuleWithFolderTitle, error) { + query := &models.GetAlertRuleByUIDQuery{ + OrgID: orgID, + UID: ruleUID, + } + err := service.ruleStore.GetAlertRuleByUID(ctx, query) + if err != nil { + return AlertRuleWithFolderTitle{}, err + } + + dq := dashboards.GetDashboardQuery{ + OrgID: orgID, + UID: query.Result.NamespaceUID, + } + + dash, err := service.dashboardService.GetDashboard(ctx, &dq) + if err != nil { + return AlertRuleWithFolderTitle{}, err + } + + return AlertRuleWithFolderTitle{ + AlertRule: *query.Result, + FolderTitle: dash.Title, + }, nil +} + // CreateAlertRule creates a new alert rule. This function will ignore any // interval that is set in the rule struct and use the already existing group // interval or the default one. @@ -114,10 +152,10 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model return rule, nil } -func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, folder, group string) (models.AlertRuleGroup, error) { +func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, namespaceUID, group string) (models.AlertRuleGroup, error) { q := models.ListAlertRulesQuery{ OrgID: orgID, - NamespaceUIDs: []string{folder}, + NamespaceUIDs: []string{namespaceUID}, RuleGroup: group, } if err := service.ruleStore.ListAlertRules(ctx, &q); err != nil { @@ -366,6 +404,114 @@ func (service *AlertRuleService) deleteRules(ctx context.Context, orgID int64, t return nil } +// GetAlertRuleGroupWithFolderTitle returns the alert rule group with folder title. +func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Context, orgID int64, namespaceUID, group string) (file.AlertRuleGroupWithFolderTitle, error) { + q := models.ListAlertRulesQuery{ + OrgID: orgID, + NamespaceUIDs: []string{namespaceUID}, + RuleGroup: group, + } + if err := service.ruleStore.ListAlertRules(ctx, &q); err != nil { + return file.AlertRuleGroupWithFolderTitle{}, err + } + if len(q.Result) == 0 { + return file.AlertRuleGroupWithFolderTitle{}, store.ErrAlertRuleGroupNotFound + } + + dq := dashboards.GetDashboardQuery{ + OrgID: orgID, + UID: namespaceUID, + } + dash, err := service.dashboardService.GetDashboard(ctx, &dq) + if err != nil { + return file.AlertRuleGroupWithFolderTitle{}, err + } + + res := file.AlertRuleGroupWithFolderTitle{ + AlertRuleGroup: &models.AlertRuleGroup{ + Title: q.Result[0].RuleGroup, + FolderUID: q.Result[0].NamespaceUID, + Interval: q.Result[0].IntervalSeconds, + Rules: []models.AlertRule{}, + }, + OrgID: orgID, + FolderTitle: dash.Title, + } + for _, r := range q.Result { + if r != nil { + res.AlertRuleGroup.Rules = append(res.AlertRuleGroup.Rules, *r) + } + } + return res, nil +} + +// GetAlertGroupsWithFolderTitle returns all groups with folder title that have at least one alert. +func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64) ([]file.AlertRuleGroupWithFolderTitle, error) { + q := models.ListAlertRulesQuery{ + OrgID: orgID, + } + + if err := service.ruleStore.ListAlertRules(ctx, &q); err != nil { + return nil, err + } + + groups := make(map[models.AlertRuleGroupKey][]models.AlertRule) + namespaces := make(map[string][]*models.AlertRuleGroupKey) + for _, r := range q.Result { + groupKey := r.GetGroupKey() + group := groups[groupKey] + group = append(group, *r) + groups[groupKey] = group + + namespaces[r.NamespaceUID] = append(namespaces[r.NamespaceUID], &groupKey) + } + + dq := dashboards.GetDashboardsQuery{ + DashboardUIDs: nil, + } + for uid := range namespaces { + dq.DashboardUIDs = append(dq.DashboardUIDs, uid) + } + + // We need folder titles for the provisioning file format. We do it this way instead of using GetUserVisibleNamespaces to avoid folder:read permissions that should not apply to those with alert.provisioning:read. + dashes, err := service.dashboardService.GetDashboards(ctx, &dq) + if err != nil { + return nil, err + } + folderUidToTitle := make(map[string]string) + for _, dash := range dashes { + folderUidToTitle[dash.UID] = dash.Title + } + + result := make([]file.AlertRuleGroupWithFolderTitle, 0) + for groupKey, rules := range groups { + title, ok := folderUidToTitle[groupKey.NamespaceUID] + if !ok { + return nil, fmt.Errorf("cannot find title for folder with uid '%s'", groupKey.NamespaceUID) + } + result = append(result, file.AlertRuleGroupWithFolderTitle{ + AlertRuleGroup: &models.AlertRuleGroup{ + Title: rules[0].RuleGroup, + FolderUID: rules[0].NamespaceUID, + Interval: rules[0].IntervalSeconds, + Rules: rules, + }, + OrgID: orgID, + FolderTitle: title, + }) + } + + // Return results in a stable manner. + sort.SliceStable(result, func(i, j int) bool { + if result[i].AlertRuleGroup.FolderUID == result[j].AlertRuleGroup.FolderUID { + return result[i].AlertRuleGroup.Title < result[j].AlertRuleGroup.Title + } + return result[i].AlertRuleGroup.FolderUID < result[j].AlertRuleGroup.FolderUID + }) + + return result, nil +} + // syncRuleGroupFields synchronizes calculated fields across multiple rules in a group. func syncGroupRuleFields(group *models.AlertRuleGroup, orgID int64) *models.AlertRuleGroup { for i := range group.Rules { diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index 40590e8059c..a4754d72a22 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -15,6 +15,7 @@ import ( "github.com/prometheus/common/model" alertingModels "github.com/grafana/alerting/alerting/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -152,16 +153,16 @@ func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, s return alerts } -// FromAlertsStateToStoppedAlert converts firingStates that have evaluation state either eval.Alerting or eval.NoData or eval.Error to models.PostableAlert that are accepted by notifiers. -// Returns a list of alert instances that have expiration time.Now -func FromAlertsStateToStoppedAlert(firingStates []*state.State, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts { +// FromAlertsStateToStoppedAlert selects only transitions from firing states (states eval.Alerting, eval.NoData, eval.Error) +// and converts them to models.PostableAlert with EndsAt set to time.Now +func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} ts := clock.Now() - for _, alertState := range firingStates { - if alertState.State == eval.Normal || alertState.State == eval.Pending { + for _, transition := range firingStates { + if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending { continue } - postableAlert := stateToPostableAlert(alertState, appURL) + postableAlert := stateToPostableAlert(transition.State, appURL) postableAlert.EndsAt = strfmt.DateTime(ts) alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert) } diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/schedule/compat_test.go index dc36ca27e1f..8443f9a4cc8 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/schedule/compat_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" alertingModels "github.com/grafana/alerting/alerting/models" + "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -222,9 +223,14 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { } evalStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.Error, eval.NoData} - states := make([]*state.State, 0, len(evalStates)) - for _, s := range evalStates { - states = append(states, randomState(s)) + states := make([]state.StateTransition, 0, len(evalStates)*len(evalStates)) + for _, to := range evalStates { + for _, from := range evalStates { + states = append(states, state.StateTransition{ + State: randomState(to), + PreviousState: from, + }) + } } clk := clock.NewMock() @@ -232,10 +238,10 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { expected := make([]models.PostableAlert, 0, len(states)) for _, s := range states { - if !(s.State == eval.Alerting || s.State == eval.Error || s.State == eval.NoData) { + if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) { continue } - alert := stateToPostableAlert(s, appURL) + alert := stateToPostableAlert(s.State, appURL) alert.EndsAt = strfmt.DateTime(clk.Now()) expected = append(expected, *alert) } diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index e07ee458d0d..d162e3d3957 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -77,17 +77,21 @@ func (r *alertRuleInfoRegistry) keyMap() map[models.AlertRuleKey]struct{} { } type ruleVersion int64 +type ruleVersionAndPauseStatus struct { + Version ruleVersion + IsPaused bool +} type alertRuleInfo struct { evalCh chan *evaluation - updateCh chan ruleVersion + updateCh chan ruleVersionAndPauseStatus ctx context.Context stop func(reason error) } func newAlertRuleInfo(parent context.Context) *alertRuleInfo { ctx, stop := util.WithCancelCause(parent) - return &alertRuleInfo{evalCh: make(chan *evaluation), updateCh: make(chan ruleVersion), ctx: ctx, stop: stop} + return &alertRuleInfo{evalCh: make(chan *evaluation), updateCh: make(chan ruleVersionAndPauseStatus), ctx: ctx, stop: stop} } // eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped. @@ -114,13 +118,13 @@ func (a *alertRuleInfo) eval(eval *evaluation) (bool, *evaluation) { } // update sends an instruction to the rule evaluation routine to update the scheduled rule to the specified version. The specified version must be later than the current version, otherwise no update will happen. -func (a *alertRuleInfo) update(lastVersion ruleVersion) bool { +func (a *alertRuleInfo) update(lastVersion ruleVersionAndPauseStatus) bool { // check if the channel is not empty. msg := lastVersion select { case v := <-a.updateCh: // if it has a version pick the greatest one. - if v > msg { + if v.Version > msg.Version { msg = v } case <-a.ctx.Done(): diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index 2e48d248955..896962af74c 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -27,7 +27,7 @@ func TestSchedule_alertRuleInfo(t *testing.T) { r := newAlertRuleInfo(context.Background()) resultCh := make(chan bool) go func() { - resultCh <- r.update(ruleVersion(rand.Int63())) + resultCh <- r.update(ruleVersionAndPauseStatus{ruleVersion(rand.Int63()), false}) }() select { case <-r.updateCh: @@ -45,19 +45,19 @@ func TestSchedule_alertRuleInfo(t *testing.T) { wg.Add(1) go func() { wg.Done() - r.update(version1) + r.update(ruleVersionAndPauseStatus{version1, false}) wg.Done() }() wg.Wait() wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started go func() { wg.Done() - r.update(version2) + r.update(ruleVersionAndPauseStatus{version2, false}) }() wg.Wait() // at this point tick 1 has already been dropped select { case version := <-r.updateCh: - require.Equal(t, version2, version) + require.Equal(t, ruleVersionAndPauseStatus{version2, false}, version) case <-time.After(5 * time.Second): t.Fatal("No message was received on eval channel") } @@ -71,19 +71,19 @@ func TestSchedule_alertRuleInfo(t *testing.T) { wg.Add(1) go func() { wg.Done() - r.update(version2) + r.update(ruleVersionAndPauseStatus{version2, false}) wg.Done() }() wg.Wait() wg.Add(2) // one when time1 is sent, another when go-routine for time2 has started go func() { wg.Done() - r.update(version1) + r.update(ruleVersionAndPauseStatus{version1, false}) }() wg.Wait() // at this point tick 1 has already been dropped select { case version := <-r.updateCh: - require.Equal(t, version2, version) + require.Equal(t, ruleVersionAndPauseStatus{version2, false}, version) case <-time.After(5 * time.Second): t.Fatal("No message was received on eval channel") } @@ -185,7 +185,7 @@ func TestSchedule_alertRuleInfo(t *testing.T) { r := newAlertRuleInfo(context.Background()) r.stop(errRuleDeleted) require.ErrorIs(t, r.ctx.Err(), errRuleDeleted) - require.False(t, r.update(ruleVersion(rand.Int63()))) + require.False(t, r.update(ruleVersionAndPauseStatus{ruleVersion(rand.Int63()), false})) }) t.Run("eval should do nothing", func(t *testing.T) { r := newAlertRuleInfo(context.Background()) @@ -237,7 +237,7 @@ func TestSchedule_alertRuleInfo(t *testing.T) { } switch rand.Intn(max) + 1 { case 1: - r.update(ruleVersion(rand.Int63())) + r.update(ruleVersionAndPauseStatus{ruleVersion(rand.Int63()), false}) case 2: r.eval(&evaluation{ scheduledAt: time.Now(), diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index ba7a93bebc5..be4d3f43137 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -12,6 +12,7 @@ import ( "go.opentelemetry.io/otel/attribute" alertingModels "github.com/grafana/alerting/alerting/models" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/datasources" @@ -37,7 +38,7 @@ type ScheduleService interface { // an error. The scheduler is terminated when this function returns. Run(context.Context) error // UpdateAlertRule notifies scheduler that a rule has been changed - UpdateAlertRule(key ngmodels.AlertRuleKey, lastVersion int64) + UpdateAlertRule(key ngmodels.AlertRuleKey, lastVersion int64, isPaused bool) // DeleteAlertRule notifies scheduler that rules have been deleted DeleteAlertRule(keys ...ngmodels.AlertRuleKey) } @@ -150,12 +151,12 @@ func (sch *schedule) Run(ctx context.Context) error { } // UpdateAlertRule looks for the active rule evaluation and commands it to update the rule -func (sch *schedule) UpdateAlertRule(key ngmodels.AlertRuleKey, lastVersion int64) { +func (sch *schedule) UpdateAlertRule(key ngmodels.AlertRuleKey, lastVersion int64, isPaused bool) { ruleInfo, err := sch.registry.get(key) if err != nil { return } - ruleInfo.update(ruleVersion(lastVersion)) + ruleInfo.update(ruleVersionAndPauseStatus{ruleVersion(lastVersion), isPaused}) } // DeleteAlertRule stops evaluation of the rule, deletes it from active rules, and cleans up state cache. @@ -314,7 +315,7 @@ func (sch *schedule) processTick(ctx context.Context, dispatcherGroup *errgroup. return readyToRun, registeredDefinitions } -func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertRuleKey, evalCh <-chan *evaluation, updateCh <-chan ruleVersion) error { +func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertRuleKey, evalCh <-chan *evaluation, updateCh <-chan ruleVersionAndPauseStatus) error { grafanaCtx = ngmodels.WithRuleKey(grafanaCtx, key) logger := sch.log.FromContext(grafanaCtx) logger.Debug("Alert rule routine started") @@ -324,14 +325,23 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR evalDuration := sch.metrics.EvalDuration.WithLabelValues(orgID) evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID) - clearState := func() { - states := sch.stateManager.ResetStateByRuleUID(grafanaCtx, key) + notify := func(states []state.StateTransition) { expiredAlerts := FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock) if len(expiredAlerts.PostableAlerts) > 0 { sch.alertsSender.Send(key, expiredAlerts) } } + resetState := func(ctx context.Context, isPaused bool) { + rule := sch.schedulableAlertRules.get(key) + reason := ngmodels.StateReasonUpdated + if isPaused { + reason = ngmodels.StateReasonPaused + } + states := sch.stateManager.ResetStateByRuleUID(ctx, rule, reason) + notify(states) + } + evaluate := func(ctx context.Context, attempt int64, e *evaluation, span tracing.Span) { logger := logger.New("version", e.rule.Version, "attempt", attempt, "now", e.scheduledAt) start := sch.clock.Now() @@ -431,18 +441,19 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR for { select { // used by external services (API) to notify that rule is updated. - case lastVersion := <-updateCh: + case ctx := <-updateCh: // sometimes it can happen when, for example, the rule evaluation took so long, // and there were two concurrent messages in updateCh and evalCh, and the eval's one got processed first. // therefore, at the time when message from updateCh is processed the current rule will have // at least the same version (or greater) and the state created for the new version of the rule. - if currentRuleVersion >= int64(lastVersion) { - logger.Info("Skip updating rule because its current version is actual", "version", currentRuleVersion, "newVersion", lastVersion) + if currentRuleVersion >= int64(ctx.Version) { + logger.Info("Skip updating rule because its current version is actual", "version", currentRuleVersion, "newVersion", ctx.Version) continue } - logger.Info("Clearing the state of the rule because version has changed", "version", currentRuleVersion, "newVersion", lastVersion) + + logger.Info("Clearing the state of the rule because it was updated", "version", currentRuleVersion, "newVersion", ctx.Version, "isPaused", ctx.IsPaused) // clear the state. So the next evaluation will start from the scratch. - clearState() + resetState(grafanaCtx, ctx.IsPaused) // evalCh - used by the scheduler to signal that evaluation is needed. case ctx, ok := <-evalCh: if !ok { @@ -462,14 +473,18 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR err := retryIfError(func(attempt int64) error { newVersion := ctx.rule.Version + isPaused := ctx.rule.IsPaused // fetch latest alert rule version if currentRuleVersion != newVersion { if currentRuleVersion > 0 { // do not clean up state if the eval loop has just started. logger.Debug("Got a new version of alert rule. Clear up the state and refresh extra labels", "version", currentRuleVersion, "newVersion", newVersion) - clearState() + resetState(grafanaCtx, isPaused) } currentRuleVersion = newVersion } + if isPaused { + return nil + } tracingCtx, span := sch.tracer.Start(grafanaCtx, "alert rule execution") defer span.End() @@ -489,7 +504,13 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR case <-grafanaCtx.Done(): // clean up the state only if the reason for stopping the evaluation loop is that the rule was deleted if errors.Is(grafanaCtx.Err(), errRuleDeleted) { - clearState() + // We do not want a context to be unbounded which could potentially cause a go routine running + // indefinitely. 1 minute is an almost randomly chosen timeout, big enough to cover the majority of the + // cases. + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer cancelFunc() + states := sch.stateManager.DeleteStateByRuleUID(ngmodels.WithRuleKey(ctx, key), key, ngmodels.StateReasonRuleDeleted) + notify(states) } logger.Debug("Stopping alert rule routine") return nil diff --git a/pkg/services/ngalert/schedule/schedule_mock.go b/pkg/services/ngalert/schedule/schedule_mock.go index 720a7366813..9a950779f32 100644 --- a/pkg/services/ngalert/schedule/schedule_mock.go +++ b/pkg/services/ngalert/schedule/schedule_mock.go @@ -36,8 +36,8 @@ func (_m *FakeScheduleService) Run(_a0 context.Context) error { } // UpdateAlertRule provides a mock function with given fields: key, lastVersion -func (_m *FakeScheduleService) UpdateAlertRule(key models.AlertRuleKey, lastVersion int64) { - _m.Called(key, lastVersion) +func (_m *FakeScheduleService) UpdateAlertRule(key models.AlertRuleKey, lastVersion int64, isPaused bool) { + _m.Called(key, lastVersion, isPaused) } // evalApplied provides a mock function with given fields: _a0, _a1 diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 2cbc59b5319..20e8f58bf79 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -235,7 +235,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersion)) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) }() expectedTime := time.UnixMicro(rand.Int63()) @@ -347,7 +347,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) go func() { - err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evaluation), make(chan ruleVersion)) + err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evaluation), make(chan ruleVersionAndPauseStatus)) stoppedChan <- err }() @@ -366,7 +366,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { ctx, cancel := util.WithCancelCause(context.Background()) go func() { - err := sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evaluation), make(chan ruleVersion)) + err := sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evaluation), make(chan ruleVersionAndPauseStatus)) stoppedChan <- err }() @@ -383,13 +383,14 @@ func TestSchedule_ruleRoutine(t *testing.T) { evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) - updateChan := make(chan ruleVersion) + updateChan := make(chan ruleVersionAndPauseStatus) sender := AlertsSenderMock{} sender.EXPECT().Send(rule.GetKey(), mock.Anything).Return() sch, ruleStore, _, _ := createSchedule(evalAppliedChan, &sender) ruleStore.PutRule(context.Background(), rule) + sch.schedulableAlertRules.set([]*models.AlertRule{rule}, map[string]string{rule.NamespaceUID: "folderName"}) go func() { ctx, cancel := context.WithCancel(context.Background()) @@ -433,9 +434,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { require.Greaterf(t, expectedToBeSent, 0, "State manager was expected to return at least one state that can be expired") t.Run("should do nothing if version in channel is the same", func(t *testing.T) { - updateChan <- ruleVersion(rule.Version - 1) - updateChan <- ruleVersion(rule.Version) - updateChan <- ruleVersion(rule.Version) // second time just to make sure that previous messages were handled + updateChan <- ruleVersionAndPauseStatus{ruleVersion(rule.Version - 1), false} + updateChan <- ruleVersionAndPauseStatus{ruleVersion(rule.Version), false} + updateChan <- ruleVersionAndPauseStatus{ruleVersion(rule.Version), false} // second time just to make sure that previous messages were handled actualStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) require.Len(t, actualStates, len(states)) @@ -444,7 +445,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) t.Run("should clear the state and expire firing alerts if version in channel is greater", func(t *testing.T) { - updateChan <- ruleVersion(rule.Version + rand.Int63n(1000) + 1) + updateChan <- ruleVersionAndPauseStatus{ruleVersion(rule.Version + rand.Int63n(1000) + 1), false} require.Eventually(t, func() bool { return len(sender.Calls) > 0 @@ -474,7 +475,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersion)) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) }() evalChan <- &evaluation{ @@ -544,7 +545,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersion)) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) }() evalChan <- &evaluation{ @@ -577,7 +578,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersion)) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan ruleVersionAndPauseStatus)) }() evalChan <- &evaluation{ @@ -601,12 +602,12 @@ func TestSchedule_UpdateAlertRule(t *testing.T) { info, _ := sch.registry.getOrCreateInfo(context.Background(), key) version := rand.Int63() go func() { - sch.UpdateAlertRule(key, version) + sch.UpdateAlertRule(key, version, false) }() select { case v := <-info.updateCh: - require.Equal(t, ruleVersion(version), v) + require.Equal(t, ruleVersionAndPauseStatus{ruleVersion(version), false}, v) case <-time.After(5 * time.Second): t.Fatal("No message was received on update channel") } @@ -616,14 +617,14 @@ func TestSchedule_UpdateAlertRule(t *testing.T) { key := models.GenerateRuleKey(rand.Int63()) info, _ := sch.registry.getOrCreateInfo(context.Background(), key) info.stop(nil) - sch.UpdateAlertRule(key, rand.Int63()) + sch.UpdateAlertRule(key, rand.Int63(), false) }) }) t.Run("when rule does not exist", func(t *testing.T) { t.Run("should exit", func(t *testing.T) { sch := setupScheduler(t, nil, nil, nil, nil, nil) key := models.GenerateRuleKey(rand.Int63()) - sch.UpdateAlertRule(key, rand.Int63()) + sch.UpdateAlertRule(key, rand.Int63(), false) }) }) } diff --git a/pkg/services/ngalert/state/historian/annotation.go b/pkg/services/ngalert/state/historian/annotation.go index 03f1b73faaa..188c9010371 100644 --- a/pkg/services/ngalert/state/historian/annotation.go +++ b/pkg/services/ngalert/state/historian/annotation.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) // AnnotationBackend is an implementation of state.Historian that uses Grafana Annotations as the backing datastore. @@ -40,7 +41,7 @@ func NewAnnotationBackend(annotations annotations.Repository, dashboards dashboa } // RecordStates writes a number of state transitions for a given rule to state history. -func (h *AnnotationBackend) RecordStatesAsync(ctx context.Context, rule *ngmodels.AlertRule, states []state.StateTransition) <-chan error { +func (h *AnnotationBackend) RecordStatesAsync(ctx context.Context, rule history_model.RuleMeta, states []state.StateTransition) <-chan error { logger := h.log.FromContext(ctx) // Build annotations before starting goroutine, to make sure all data is copied and won't mutate underneath us. annotations := h.buildAnnotations(rule, states, logger) @@ -136,7 +137,7 @@ func (h *AnnotationBackend) QueryStates(ctx context.Context, query ngmodels.Hist return frame, nil } -func (h *AnnotationBackend) buildAnnotations(rule *ngmodels.AlertRule, states []state.StateTransition, logger log.Logger) []annotations.Item { +func (h *AnnotationBackend) buildAnnotations(rule history_model.RuleMeta, states []state.StateTransition, logger log.Logger) []annotations.Item { items := make([]annotations.Item, 0, len(states)) for _, state := range states { if !shouldRecord(state) { @@ -184,7 +185,7 @@ func (h *AnnotationBackend) recordAnnotationsSync(ctx context.Context, panel *pa return nil } -func buildAnnotationTextAndData(rule *ngmodels.AlertRule, currentState *state.State) (string, *simplejson.Json) { +func buildAnnotationTextAndData(rule history_model.RuleMeta, currentState *state.State) (string, *simplejson.Json) { jsonData := simplejson.New() var value string diff --git a/pkg/services/ngalert/state/historian/core.go b/pkg/services/ngalert/state/historian/core.go index eef8e7e7236..d8d49868413 100644 --- a/pkg/services/ngalert/state/historian/core.go +++ b/pkg/services/ngalert/state/historian/core.go @@ -1,7 +1,6 @@ package historian import ( - "strconv" "strings" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -9,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) func shouldRecord(transition state.StateTransition) bool { @@ -37,19 +37,12 @@ type panelKey struct { } // panelKey attempts to get the key of the panel attached to the given rule. Returns nil if the rule is not attached to a panel. -func parsePanelKey(rule *models.AlertRule, logger log.Logger) *panelKey { - dashUID, ok := rule.Annotations[models.DashboardUIDAnnotation] - if ok { - panelAnno := rule.Annotations[models.PanelIDAnnotation] - panelID, err := strconv.ParseInt(panelAnno, 10, 64) - if err != nil { - logger.Error("Error parsing panelUID for alert annotation", "actual", panelAnno, "error", err) - return nil - } +func parsePanelKey(rule history_model.RuleMeta, logger log.Logger) *panelKey { + if rule.DashboardUID != "" { return &panelKey{ orgID: rule.OrgID, - dashUID: dashUID, - panelID: panelID, + dashUID: rule.DashboardUID, + panelID: rule.PanelID, } } return nil diff --git a/pkg/services/ngalert/state/historian/core_test.go b/pkg/services/ngalert/state/historian/core_test.go index b9a2cfd1108..0eae1303640 100644 --- a/pkg/services/ngalert/state/historian/core_test.go +++ b/pkg/services/ngalert/state/historian/core_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -153,69 +152,3 @@ func TestRemovePrivateLabels(t *testing.T) { }) } } - -func TestParsePanelKey(t *testing.T) { - logger := log.NewNopLogger() - - type testCase struct { - name string - in models.AlertRule - exp *panelKey - } - - cases := []testCase{ - { - name: "no dash UID", - in: models.AlertRule{ - OrgID: 1, - Annotations: map[string]string{ - models.PanelIDAnnotation: "123", - }, - }, - exp: nil, - }, - { - name: "no panel ID", - in: models.AlertRule{ - OrgID: 1, - Annotations: map[string]string{ - models.DashboardUIDAnnotation: "abcd-uid", - }, - }, - exp: nil, - }, - { - name: "invalid panel ID", - in: models.AlertRule{ - OrgID: 1, - Annotations: map[string]string{ - models.DashboardUIDAnnotation: "abcd-uid", - models.PanelIDAnnotation: "bad-id", - }, - }, - exp: nil, - }, - { - name: "success", - in: models.AlertRule{ - OrgID: 1, - Annotations: map[string]string{ - models.DashboardUIDAnnotation: "abcd-uid", - models.PanelIDAnnotation: "123", - }, - }, - exp: &panelKey{ - orgID: 1, - dashUID: "abcd-uid", - panelID: 123, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - res := parsePanelKey(&tc.in, logger) - require.Equal(t, tc.exp, res) - }) - } -} diff --git a/pkg/services/ngalert/state/historian/dashboard.go b/pkg/services/ngalert/state/historian/dashboard.go index ec311e6ff05..74b097298e2 100644 --- a/pkg/services/ngalert/state/historian/dashboard.go +++ b/pkg/services/ngalert/state/historian/dashboard.go @@ -54,16 +54,16 @@ func (r *dashboardResolver) getID(ctx context.Context, orgID int64, uid string) UID: uid, OrgID: orgID, } - err := r.dashboards.GetDashboard(ctx, query) + queryResult, err := r.dashboards.GetDashboard(ctx, query) // We also cache lookups where we don't find anything. if err != nil && errors.Is(err, dashboards.ErrDashboardNotFound) { result = err } else if err != nil { return 0, err - } else if query.Result == nil { + } else if queryResult == nil { result = dashboards.ErrDashboardNotFound } else { - result = query.Result.ID + result = queryResult.ID } // By setting the cache inside the singleflighted routine, we avoid any accidental re-queries that could get initiated after the query completes. diff --git a/pkg/services/ngalert/state/historian/dashboard_test.go b/pkg/services/ngalert/state/historian/dashboard_test.go index f8e7dba20a4..f455ef0f70d 100644 --- a/pkg/services/ngalert/state/historian/dashboard_test.go +++ b/pkg/services/ngalert/state/historian/dashboard_test.go @@ -14,9 +14,8 @@ func TestDashboardResolver(t *testing.T) { t.Run("fetches dashboards from dashboard service", func(t *testing.T) { dbs := &dashboards.FakeDashboardService{} exp := int64(14) - dbs.On("GetDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - args.Get(1).(*dashboards.GetDashboardQuery).Result = &dashboards.Dashboard{ID: exp} - }).Return(nil) + result := &dashboards.Dashboard{ID: exp} + dbs.On("GetDashboard", mock.Anything, mock.Anything).Return(result, nil) sut := createDashboardResolverSut(dbs) id, err := sut.getID(context.Background(), 1, "dashboard-uid") @@ -27,9 +26,7 @@ func TestDashboardResolver(t *testing.T) { t.Run("fetches dashboardNotFound if underlying dashboard does not exist", func(t *testing.T) { dbs := &dashboards.FakeDashboardService{} - dbs.On("GetDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - args.Get(1).(*dashboards.GetDashboardQuery).Result = nil - }).Return(dashboards.ErrDashboardNotFound) + dbs.On("GetDashboard", mock.Anything, mock.Anything).Return(nil, dashboards.ErrDashboardNotFound) sut := createDashboardResolverSut(dbs) _, err := sut.getID(context.Background(), 1, "not-exist") diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index dcb6dd2da67..466fc6137c4 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) const ( @@ -22,8 +23,8 @@ const ( ) type remoteLokiClient interface { - ping() error - push([]stream) error + ping(context.Context) error + push(context.Context, []stream) error } type RemoteLokiBackend struct { @@ -39,11 +40,11 @@ func NewRemoteLokiBackend(cfg LokiConfig) *RemoteLokiBackend { } } -func (h *RemoteLokiBackend) TestConnection() error { - return h.client.ping() +func (h *RemoteLokiBackend) TestConnection(ctx context.Context) error { + return h.client.ping(ctx) } -func (h *RemoteLokiBackend) RecordStatesAsync(ctx context.Context, rule *models.AlertRule, states []state.StateTransition) <-chan error { +func (h *RemoteLokiBackend) RecordStatesAsync(ctx context.Context, rule history_model.RuleMeta, states []state.StateTransition) <-chan error { logger := h.log.FromContext(ctx) streams := h.statesToStreams(rule, states, logger) return h.recordStreamsAsync(ctx, streams, logger) @@ -53,7 +54,7 @@ func (h *RemoteLokiBackend) QueryStates(ctx context.Context, query models.Histor return data.NewFrame("states"), nil } -func (h *RemoteLokiBackend) statesToStreams(rule *models.AlertRule, states []state.StateTransition, logger log.Logger) []stream { +func (h *RemoteLokiBackend) statesToStreams(rule history_model.RuleMeta, states []state.StateTransition, logger log.Logger) []stream { buckets := make(map[string][]row) // label repr -> entries for _, state := range states { if !shouldRecord(state) { @@ -63,7 +64,7 @@ func (h *RemoteLokiBackend) statesToStreams(rule *models.AlertRule, states []sta labels := removePrivateLabels(state.State.Labels) labels[OrgIDLabel] = fmt.Sprint(rule.OrgID) labels[RuleUIDLabel] = fmt.Sprint(rule.UID) - labels[GroupLabel] = fmt.Sprint(rule.RuleGroup) + labels[GroupLabel] = fmt.Sprint(rule.Group) labels[FolderUIDLabel] = fmt.Sprint(rule.NamespaceUID) repr := labels.String() @@ -115,7 +116,7 @@ func (h *RemoteLokiBackend) recordStreamsAsync(ctx context.Context, streams []st } func (h *RemoteLokiBackend) recordStreams(ctx context.Context, streams []stream, logger log.Logger) error { - if err := h.client.push(streams); err != nil { + if err := h.client.push(ctx, streams); err != nil { return err } logger.Debug("Done saving alert state history batch") diff --git a/pkg/services/ngalert/state/historian/loki_http.go b/pkg/services/ngalert/state/historian/loki_http.go index 457516b0b4f..708c48965a9 100644 --- a/pkg/services/ngalert/state/historian/loki_http.go +++ b/pkg/services/ngalert/state/historian/loki_http.go @@ -2,6 +2,7 @@ package historian import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -37,7 +38,7 @@ func newLokiClient(cfg LokiConfig, logger log.Logger) *httpLokiClient { } } -func (c *httpLokiClient) ping() error { +func (c *httpLokiClient) ping(ctx context.Context) error { uri := c.cfg.Url.JoinPath("/loki/api/v1/labels") req, err := http.NewRequest(http.MethodGet, uri.String(), nil) if err != nil { @@ -45,6 +46,7 @@ func (c *httpLokiClient) ping() error { } c.setAuthAndTenantHeaders(req) + req = req.WithContext(ctx) res, err := c.client.Do(req) if res != nil { defer func() { @@ -80,7 +82,7 @@ func (r *row) MarshalJSON() ([]byte, error) { }) } -func (c *httpLokiClient) push(s []stream) error { +func (c *httpLokiClient) push(ctx context.Context, s []stream) error { body := struct { Streams []stream `json:"streams"` }{Streams: s} @@ -98,6 +100,7 @@ func (c *httpLokiClient) push(s []stream) error { c.setAuthAndTenantHeaders(req) req.Header.Add("content-type", "application/json") + req = req.WithContext(ctx) resp, err := c.client.Do(req) if resp != nil { defer func() { diff --git a/pkg/services/ngalert/state/historian/loki_http_test.go b/pkg/services/ngalert/state/historian/loki_http_test.go index 89947ccd8fc..f9ec280e4f1 100644 --- a/pkg/services/ngalert/state/historian/loki_http_test.go +++ b/pkg/services/ngalert/state/historian/loki_http_test.go @@ -1,6 +1,7 @@ package historian import ( + "context" "net/url" "testing" @@ -21,7 +22,7 @@ func TestLokiHTTPClient(t *testing.T) { }, log.NewNopLogger()) // Unauthorized request should fail against Grafana Cloud. - err = client.ping() + err = client.ping(context.Background()) require.Error(t, err) client.cfg.BasicAuthUser = "" @@ -32,7 +33,7 @@ func TestLokiHTTPClient(t *testing.T) { // client.cfg.TenantID = "" // Authorized request should fail against Grafana Cloud. - err = client.ping() + err = client.ping(context.Background()) require.NoError(t, err) }) } diff --git a/pkg/services/ngalert/state/historian/model/rule.go b/pkg/services/ngalert/state/historian/model/rule.go new file mode 100644 index 00000000000..a63656c7560 --- /dev/null +++ b/pkg/services/ngalert/state/historian/model/rule.go @@ -0,0 +1,46 @@ +package model + +import ( + "strconv" + + "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// RuleMeta is the metadata about a rule that is needed by state history. +type RuleMeta struct { + ID int64 + OrgID int64 + UID string + Title string + Group string + NamespaceUID string + DashboardUID string + PanelID int64 +} + +func NewRuleMeta(r *models.AlertRule, log log.Logger) RuleMeta { + dashUID, ok := r.Annotations[models.DashboardUIDAnnotation] + var panelID int64 + if ok { + panelAnno := r.Annotations[models.PanelIDAnnotation] + pid, err := strconv.ParseInt(panelAnno, 10, 64) + if err != nil { + logger.Error("Error parsing panelUID for alert annotation", "ruleID", r.ID, "dash", dashUID, "actual", panelAnno, "error", err) + pid = 0 + dashUID = "" + } + panelID = pid + } + return RuleMeta{ + ID: r.ID, + OrgID: r.OrgID, + UID: r.UID, + Title: r.Title, + Group: r.RuleGroup, + NamespaceUID: r.NamespaceUID, + DashboardUID: dashUID, + PanelID: panelID, + } +} diff --git a/pkg/services/ngalert/state/historian/model/rule_test.go b/pkg/services/ngalert/state/historian/model/rule_test.go new file mode 100644 index 00000000000..228e9537307 --- /dev/null +++ b/pkg/services/ngalert/state/historian/model/rule_test.go @@ -0,0 +1,77 @@ +package model + +import ( + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/stretchr/testify/require" +) + +func TestNewRuleMeta(t *testing.T) { + logger := log.NewNopLogger() + + type testCase struct { + name string + in models.AlertRule + expDash string + expPanel int64 + } + + cases := []testCase{ + { + name: "no dash UID", + in: models.AlertRule{ + OrgID: 1, + Annotations: map[string]string{ + models.PanelIDAnnotation: "123", + }, + }, + expDash: "", + expPanel: 0, + }, + { + name: "no panel ID", + in: models.AlertRule{ + OrgID: 1, + Annotations: map[string]string{ + models.DashboardUIDAnnotation: "abcd-uid", + }, + }, + expDash: "", + expPanel: 0, + }, + { + name: "invalid panel ID", + in: models.AlertRule{ + OrgID: 1, + Annotations: map[string]string{ + models.DashboardUIDAnnotation: "abcd-uid", + models.PanelIDAnnotation: "bad-id", + }, + }, + expDash: "", + expPanel: 0, + }, + { + name: "success", + in: models.AlertRule{ + OrgID: 1, + Annotations: map[string]string{ + models.DashboardUIDAnnotation: "abcd-uid", + models.PanelIDAnnotation: "123", + }, + }, + expDash: "abcd-uid", + expPanel: 123, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := NewRuleMeta(&tc.in, logger) + require.Equal(t, tc.expDash, res.DashboardUID) + require.Equal(t, tc.expPanel, res.PanelID) + }) + } +} diff --git a/pkg/services/ngalert/state/historian/noop.go b/pkg/services/ngalert/state/historian/noop.go index a4b7ce1126b..2acee1ace1f 100644 --- a/pkg/services/ngalert/state/historian/noop.go +++ b/pkg/services/ngalert/state/historian/noop.go @@ -3,8 +3,8 @@ package historian import ( "context" - "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) // NoOpHistorian is a state.Historian that does nothing with the resulting data, to be used in contexts where history is not needed. @@ -14,7 +14,7 @@ func NewNopHistorian() *NoOpHistorian { return &NoOpHistorian{} } -func (f *NoOpHistorian) RecordStatesAsync(ctx context.Context, _ *models.AlertRule, _ []state.StateTransition) <-chan error { +func (f *NoOpHistorian) RecordStatesAsync(ctx context.Context, _ history_model.RuleMeta, _ []state.StateTransition) <-chan error { errCh := make(chan error) close(errCh) return errCh diff --git a/pkg/services/ngalert/state/historian/sql.go b/pkg/services/ngalert/state/historian/sql.go index 8e5d0cf2294..64e716204b8 100644 --- a/pkg/services/ngalert/state/historian/sql.go +++ b/pkg/services/ngalert/state/historian/sql.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) type SqlBackend struct { @@ -19,7 +20,7 @@ func NewSqlBackend() *SqlBackend { } } -func (h *SqlBackend) RecordStatesAsync(ctx context.Context, _ *models.AlertRule, _ []state.StateTransition) <-chan error { +func (h *SqlBackend) RecordStatesAsync(ctx context.Context, _ history_model.RuleMeta, _ []state.StateTransition) <-chan error { errCh := make(chan error) close(errCh) return errCh diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 41b06c37f41..76e65c06434 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) var ( @@ -164,19 +165,71 @@ func (st *Manager) Get(orgID int64, alertRuleUID, stateId string) *State { return st.cache.get(orgID, alertRuleUID, stateId) } -// ResetStateByRuleUID deletes all entries in the state manager that match the given rule UID. -func (st *Manager) ResetStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKey) []*State { - logger := st.log.New(ruleKey.LogContext()...) +// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. A closed channel is returned to be able +// to gracefully handle the clear state step in scheduler in case we do not need to use the historian to save state +// history. +func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKey, reason string) []StateTransition { + logger := st.log.FromContext(ctx) logger.Debug("Resetting state of the rule") + states := st.cache.removeByRuleUID(ruleKey.OrgID, ruleKey.UID) - if len(states) > 0 && st.instanceStore != nil { + + if len(states) == 0 { + return nil + } + + now := st.clock.Now() + transitions := make([]StateTransition, 0, len(states)) + for _, s := range states { + oldState := s.State + oldReason := s.StateReason + startsAt := s.StartsAt + if s.State != eval.Normal { + startsAt = now + } + s.SetNormal(reason, startsAt, now) + // Set Resolved property so the scheduler knows to send a postable alert + // to Alertmanager. + s.Resolved = oldState == eval.Alerting + s.LastEvaluationTime = now + s.Values = map[string]float64{} + transitions = append(transitions, StateTransition{ + State: s, + PreviousState: oldState, + PreviousStateReason: oldReason, + }) + } + + if st.instanceStore != nil { err := st.instanceStore.DeleteAlertInstancesByRule(ctx, ruleKey) if err != nil { logger.Error("Failed to delete states that belong to a rule from database", "error", err) } } logger.Info("Rules state was reset", "states", len(states)) - return states + + return transitions +} + +// ResetStateByRuleUID removes the rule instances from cache and instanceStore and saves state history. If the state +// history has to be saved, rule must not be nil. +func (st *Manager) ResetStateByRuleUID(ctx context.Context, rule *ngModels.AlertRule, reason string) []StateTransition { + ruleKey := rule.GetKey() + transitions := st.DeleteStateByRuleUID(ctx, ruleKey, reason) + + if rule == nil || st.historian == nil || len(transitions) == 0 { + return transitions + } + + ruleMeta := history_model.NewRuleMeta(rule, st.log) + errCh := st.historian.RecordStatesAsync(ctx, ruleMeta, transitions) + go func() { + err := <-errCh + if err != nil { + st.log.FromContext(ctx).Error("Error updating historian state reset transitions", append(ruleKey.LogContext(), "reason", reason, "error", err)...) + } + }() + return transitions } // ProcessEvalResults updates the current states that belong to a rule with the evaluation results. @@ -197,7 +250,7 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time allChanges := append(states, staleStates...) if st.historian != nil { - st.historian.RecordStatesAsync(ctx, alertRule, allChanges) + st.historian.RecordStatesAsync(ctx, history_model.NewRuleMeta(alertRule, logger), allChanges) } return allChanges } @@ -239,7 +292,7 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu logger.Debug("Ignoring set next state as result is pending") } - // Set reason iff: result is different than state, reason is not Alerting or Normal + // Set reason iff: result and state are different, reason is not Alerting or Normal currentState.StateReason = "" if currentState.State != result.State && diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index ca96f4f6de9..48fc6998f23 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/state/historian" "github.com/grafana/grafana/pkg/services/ngalert/tests" + "github.com/grafana/grafana/pkg/util" ) var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry()) @@ -2543,3 +2544,277 @@ func TestStaleResults(t *testing.T) { } }) } + +func TestDeleteStateByRuleUID(t *testing.T) { + interval := time.Minute + ctx := context.Background() + _, dbstore := tests.SetupTestEnv(t, 1) + + const mainOrgID int64 = 1 + rule := tests.CreateTestAlertRule(t, ctx, dbstore, int64(interval.Seconds()), mainOrgID) + + labels1 := models.InstanceLabels{"test1": "testValue1"} + _, hash1, _ := labels1.StringAndHash() + labels2 := models.InstanceLabels{"test2": "testValue2"} + _, hash2, _ := labels2.StringAndHash() + instances := []models.AlertInstance{ + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleOrgID: rule.OrgID, + RuleUID: rule.UID, + LabelsHash: hash1, + }, + CurrentState: models.InstanceStateNormal, + Labels: labels1, + }, + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleOrgID: rule.OrgID, + RuleUID: rule.UID, + LabelsHash: hash2, + }, + CurrentState: models.InstanceStateFiring, + Labels: labels2, + }, + } + + _ = dbstore.SaveAlertInstances(ctx, instances...) + + testCases := []struct { + desc string + instanceStore state.InstanceStore + + expectedStates map[string]*state.State + + startingStateCacheCount int + finalStateCacheCount int + startingInstanceDBCount int + finalInstanceDBCount int + }{ + { + desc: "all states/instances are removed from cache and DB", + instanceStore: dbstore, + expectedStates: map[string]*state.State{ + `[["test1","testValue1"]]`: { + AlertRuleUID: rule.UID, + OrgID: 1, + CacheID: `[["test1","testValue1"]]`, + Labels: data.Labels{"test1": "testValue1"}, + State: eval.Normal, + EvaluationDuration: 0, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + }, + `[["test2","testValue2"]]`: { + AlertRuleUID: rule.UID, + OrgID: 1, + CacheID: `[["test2","testValue2"]]`, + Labels: data.Labels{"test2": "testValue2"}, + State: eval.Alerting, + EvaluationDuration: 0, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + }, + }, + startingStateCacheCount: 2, + finalStateCacheCount: 0, + startingInstanceDBCount: 2, + finalInstanceDBCount: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ctx := context.Background() + clk := clock.NewMock() + clk.Set(time.Now()) + cfg := state.ManagerCfg{ + Metrics: testMetrics.GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clk, + Historian: &state.FakeHistorian{}, + } + st := state.NewManager(cfg) + st.Warm(ctx, dbstore) + q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} + _ = dbstore.ListAlertInstances(ctx, q) + existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID) + + // We have loaded the expected number of entries from the db + assert.Equal(t, tc.startingStateCacheCount, len(existingStatesForRule)) + assert.Equal(t, tc.startingInstanceDBCount, len(q.Result)) + + expectedReason := util.GenerateShortUID() + transitions := st.DeleteStateByRuleUID(ctx, rule.GetKey(), expectedReason) + + // Check that the deleted states are the same as the ones that were in cache + assert.Equal(t, tc.startingStateCacheCount, len(transitions)) + for _, s := range transitions { + assert.Contains(t, tc.expectedStates, s.CacheID) + oldState := tc.expectedStates[s.CacheID] + assert.Equal(t, oldState.State, s.PreviousState) + assert.Equal(t, oldState.StateReason, s.PreviousStateReason) + assert.Equal(t, eval.Normal, s.State.State) + assert.Equal(t, expectedReason, s.StateReason) + if oldState.State == eval.Normal { + assert.Equal(t, oldState.StartsAt, s.StartsAt) + assert.False(t, s.Resolved) + } else { + assert.Equal(t, clk.Now(), s.StartsAt) + if oldState.State == eval.Alerting { + assert.True(t, s.Resolved) + } + } + assert.Equal(t, clk.Now(), s.EndsAt) + } + + q = &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} + _ = dbstore.ListAlertInstances(ctx, q) + existingStatesForRule = st.GetStatesForRuleUID(rule.OrgID, rule.UID) + + // The expected number of state entries remains after states are deleted + assert.Equal(t, tc.finalStateCacheCount, len(existingStatesForRule)) + assert.Equal(t, tc.finalInstanceDBCount, len(q.Result)) + }) + } +} + +func TestResetStateByRuleUID(t *testing.T) { + interval := time.Minute + ctx := context.Background() + _, dbstore := tests.SetupTestEnv(t, 1) + + const mainOrgID int64 = 1 + rule := tests.CreateTestAlertRule(t, ctx, dbstore, int64(interval.Seconds()), mainOrgID) + + labels1 := models.InstanceLabels{"test1": "testValue1"} + _, hash1, _ := labels1.StringAndHash() + labels2 := models.InstanceLabels{"test2": "testValue2"} + _, hash2, _ := labels2.StringAndHash() + instances := []models.AlertInstance{ + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleOrgID: rule.OrgID, + RuleUID: rule.UID, + LabelsHash: hash1, + }, + CurrentState: models.InstanceStateNormal, + Labels: labels1, + }, + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleOrgID: rule.OrgID, + RuleUID: rule.UID, + LabelsHash: hash2, + }, + CurrentState: models.InstanceStateFiring, + Labels: labels2, + }, + } + + _ = dbstore.SaveAlertInstances(ctx, instances...) + + testCases := []struct { + desc string + instanceStore state.InstanceStore + + expectedStates map[string]*state.State + + startingStateCacheCount int + finalStateCacheCount int + startingInstanceDBCount int + finalInstanceDBCount int + newHistorianEntriesCount int + }{ + { + desc: "all states/instances are removed from cache and DB and saved in historian", + instanceStore: dbstore, + expectedStates: map[string]*state.State{ + `[["test1","testValue1"]]`: { + AlertRuleUID: rule.UID, + OrgID: 1, + CacheID: `[["test1","testValue1"]]`, + Labels: data.Labels{"test1": "testValue1"}, + State: eval.Normal, + EvaluationDuration: 0, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + }, + `[["test2","testValue2"]]`: { + AlertRuleUID: rule.UID, + OrgID: 1, + CacheID: `[["test2","testValue2"]]`, + Labels: data.Labels{"test2": "testValue2"}, + State: eval.Alerting, + EvaluationDuration: 0, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + }, + }, + startingStateCacheCount: 2, + finalStateCacheCount: 0, + startingInstanceDBCount: 2, + finalInstanceDBCount: 0, + newHistorianEntriesCount: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ctx := context.Background() + fakeHistorian := &state.FakeHistorian{StateTransitions: make([]state.StateTransition, 0)} + clk := clock.NewMock() + clk.Set(time.Now()) + cfg := state.ManagerCfg{ + Metrics: testMetrics.GetStateMetrics(), + ExternalURL: nil, + InstanceStore: dbstore, + Images: &state.NoopImageService{}, + Clock: clk, + Historian: fakeHistorian, + } + st := state.NewManager(cfg) + st.Warm(ctx, dbstore) + q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} + _ = dbstore.ListAlertInstances(ctx, q) + existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID) + + // We have loaded the expected number of entries from the db + assert.Equal(t, tc.startingStateCacheCount, len(existingStatesForRule)) + assert.Equal(t, tc.startingInstanceDBCount, len(q.Result)) + + transitions := st.ResetStateByRuleUID(ctx, rule, models.StateReasonPaused) + + // Check that the deleted states are the same as the ones that were in cache + assert.Equal(t, tc.startingStateCacheCount, len(transitions)) + for _, s := range transitions { + assert.Contains(t, tc.expectedStates, s.CacheID) + oldState := tc.expectedStates[s.CacheID] + assert.Equal(t, oldState.State, s.PreviousState) + assert.Equal(t, oldState.StateReason, s.PreviousStateReason) + assert.Equal(t, eval.Normal, s.State.State) + assert.Equal(t, models.StateReasonPaused, s.StateReason) + if oldState.State == eval.Normal { + assert.Equal(t, oldState.StartsAt, s.StartsAt) + assert.False(t, s.Resolved) + } else { + assert.Equal(t, clk.Now(), s.StartsAt) + if oldState.State == eval.Alerting { + assert.True(t, s.Resolved) + } + } + assert.Equal(t, clk.Now(), s.EndsAt) + } + + // Check if both entries have been added to the historian + assert.Equal(t, tc.newHistorianEntriesCount, len(fakeHistorian.StateTransitions)) + assert.Equal(t, transitions, fakeHistorian.StateTransitions) + + q = &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID} + _ = dbstore.ListAlertInstances(ctx, q) + existingStatesForRule = st.GetStatesForRuleUID(rule.OrgID, rule.UID) + + // The expected number of state entries remains after states are deleted + assert.Equal(t, tc.finalStateCacheCount, len(existingStatesForRule)) + assert.Equal(t, tc.finalInstanceDBCount, len(q.Result)) + }) + } +} diff --git a/pkg/services/ngalert/state/persist.go b/pkg/services/ngalert/state/persist.go index 1665444d248..237235f2627 100644 --- a/pkg/services/ngalert/state/persist.go +++ b/pkg/services/ngalert/state/persist.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/services/ngalert/models" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) // InstanceStore represents the ability to fetch and write alert instances. @@ -25,7 +26,7 @@ type Historian interface { // RecordStates writes a number of state transitions for a given rule to state history. It returns a channel that // is closed when writing the state transitions has completed. If an error has occurred, the channel will contain a // non-nil error. - RecordStatesAsync(ctx context.Context, rule *models.AlertRule, states []StateTransition) <-chan error + RecordStatesAsync(ctx context.Context, rule history_model.RuleMeta, states []StateTransition) <-chan error } // ImageCapturer captures images. diff --git a/pkg/services/ngalert/state/testing.go b/pkg/services/ngalert/state/testing.go index 199d07d7060..c1d60a3fbb3 100644 --- a/pkg/services/ngalert/state/testing.go +++ b/pkg/services/ngalert/state/testing.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/grafana/grafana/pkg/services/ngalert/models" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" "github.com/grafana/grafana/pkg/services/screenshot" ) @@ -60,9 +61,12 @@ func (f *FakeRuleReader) ListAlertRules(_ context.Context, q *models.ListAlertRu return nil } -type FakeHistorian struct{} +type FakeHistorian struct { + StateTransitions []StateTransition +} -func (f *FakeHistorian) RecordStatesAsync(ctx context.Context, rule *models.AlertRule, states []StateTransition) <-chan error { +func (f *FakeHistorian) RecordStatesAsync(ctx context.Context, rule history_model.RuleMeta, states []StateTransition) <-chan error { + f.StateTransitions = append(f.StateTransitions, states...) errCh := make(chan error) close(errCh) return errCh diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 6151f9f239b..74e7b2f8387 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -67,8 +68,8 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI } // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace -func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) { - var keys []ngmodels.AlertRuleKeyWithVersion +func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) { + var keys []ngmodels.AlertRuleKeyWithVersionAndPauseStatus err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { now := TimeNow() _, err := sess.Exec("UPDATE alert_rule SET version = version + 1, updated = ? WHERE namespace_uid = ? AND org_id = ?", now, namespaceUID, orgID) @@ -326,12 +327,12 @@ func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespa func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user *user.SignedInUser) (map[string]*folder.Folder, error) { namespaceMap := make(map[string]*folder.Folder) - searchQuery := models.FindPersistedDashboardsQuery{ + searchQuery := dashboards.FindPersistedDashboardsQuery{ OrgId: orgID, SignedInUser: user, Type: searchstore.TypeAlertFolder, Limit: -1, - Permission: models.PERMISSION_VIEW, + Permission: dashboards.PERMISSION_VIEW, Sort: models.SortOption{}, Filters: []interface{}{ searchstore.FolderWithAlertsFilter{}, diff --git a/pkg/services/ngalert/store/deltas_test.go b/pkg/services/ngalert/store/deltas_test.go index d3e63758e88..e9bc7b947ba 100644 --- a/pkg/services/ngalert/store/deltas_test.go +++ b/pkg/services/ngalert/store/deltas_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - grafana_models "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/util" @@ -194,7 +194,7 @@ func TestCalculateChanges(t *testing.T) { groupKey := models.AlertRuleGroupKey{ OrgID: orgId, - NamespaceUID: namespace.Uid, + NamespaceUID: namespace.UID, RuleGroup: groupName, } @@ -442,12 +442,12 @@ func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) { } } -func randFolder() *grafana_models.Folder { - return &grafana_models.Folder{ - Id: rand.Int63(), - Uid: util.GenerateShortUID(), +func randFolder() *folder.Folder { + return &folder.Folder{ + ID: rand.Int63(), + UID: util.GenerateShortUID(), Title: "TEST-FOLDER-" + util.GenerateShortUID(), - Url: "", + URL: "", Version: 0, Created: time.Time{}, Updated: time.Time{}, diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index f970ed86da8..55128376ecb 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -316,7 +316,7 @@ func (f *RuleStore) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceU return nil } -func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersion, error) { +func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersionAndPauseStatus, error) { f.mtx.Lock() defer f.mtx.Unlock() @@ -325,15 +325,18 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org Params: []interface{}{orgID, namespaceUID}, }) - var result []models.AlertRuleKeyWithVersion + var result []models.AlertRuleKeyWithVersionAndPauseStatus for _, rule := range f.Rules[orgID] { if rule.NamespaceUID == namespaceUID && rule.OrgID == orgID { rule.Version++ rule.Updated = time.Now() - result = append(result, models.AlertRuleKeyWithVersion{ - Version: rule.Version, - AlertRuleKey: rule.GetKey(), + result = append(result, models.AlertRuleKeyWithVersionAndPauseStatus{ + IsPaused: rule.IsPaused, + AlertRuleKeyWithVersion: models.AlertRuleKeyWithVersion{ + Version: rule.Version, + AlertRuleKey: rule.GetKey(), + }, }) } } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 28c7a55e4e0..2d07d32d163 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -81,6 +82,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, dashboardService := dashboardservice.ProvideDashboardService( cfg, dashboardStore, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, + foldertest.NewFakeService(), ) tracer := tracing.InitializeTracerForTest() diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index f2fc1118ac5..c773abdab2c 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" @@ -38,9 +37,9 @@ type Service struct { type OAuthTokenService interface { GetCurrentOAuthToken(context.Context, *user.SignedInUser) *oauth2.Token IsOAuthPassThruEnabled(*datasources.DataSource) bool - HasOAuthEntry(context.Context, *user.SignedInUser) (*models.UserAuth, bool, error) - TryTokenRefresh(context.Context, *models.UserAuth) error - InvalidateOAuthTokens(context.Context, *models.UserAuth) error + HasOAuthEntry(context.Context, *user.SignedInUser) (*login.UserAuth, bool, error) + TryTokenRefresh(context.Context, *login.UserAuth) error + InvalidateOAuthTokens(context.Context, *login.UserAuth) error } func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg) *Service { @@ -59,7 +58,7 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr *user.SignedInUs return nil } - authInfoQuery := &models.GetAuthInfoQuery{UserId: usr.UserID} + authInfoQuery := &login.GetAuthInfoQuery{UserId: usr.UserID} if err := o.AuthInfoService.GetAuthInfo(ctx, authInfoQuery); err != nil { if errors.Is(err, user.ErrUserNotFound) { // Not necessarily an error. User may be logged in another way. @@ -88,13 +87,13 @@ func (o *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool { } // HasOAuthEntry returns true and the UserAuth object when OAuth info exists for the specified User -func (o *Service) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*models.UserAuth, bool, error) { +func (o *Service) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*login.UserAuth, bool, error) { if usr == nil { // No user, therefore no token return nil, false, nil } - authInfoQuery := &models.GetAuthInfoQuery{UserId: usr.UserID} + authInfoQuery := &login.GetAuthInfoQuery{UserId: usr.UserID} err := o.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) if err != nil { if errors.Is(err, user.ErrUserNotFound) { @@ -112,7 +111,7 @@ func (o *Service) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*m // TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful // It uses a singleflight.Group to prevent getting the Refresh Token multiple times for a given User -func (o *Service) TryTokenRefresh(ctx context.Context, usr *models.UserAuth) error { +func (o *Service) TryTokenRefresh(ctx context.Context, usr *login.UserAuth) error { lockKey := fmt.Sprintf("oauth-refresh-token-%d", usr.UserId) _, err, _ := o.singleFlightGroup.Do(lockKey, func() (interface{}, error) { logger.Debug("singleflight request for getting a new access token", "key", lockKey) @@ -122,7 +121,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr *models.UserAuth) err return err } -func buildOAuthTokenFromAuthInfo(authInfo *models.UserAuth) *oauth2.Token { +func buildOAuthTokenFromAuthInfo(authInfo *login.UserAuth) *oauth2.Token { token := &oauth2.Token{ AccessToken: authInfo.OAuthAccessToken, Expiry: authInfo.OAuthExpiry, @@ -137,7 +136,7 @@ func buildOAuthTokenFromAuthInfo(authInfo *models.UserAuth) *oauth2.Token { return token } -func checkOAuthRefreshToken(authInfo *models.UserAuth) error { +func checkOAuthRefreshToken(authInfo *login.UserAuth) error { if !strings.Contains(authInfo.AuthModule, "oauth") { logger.Warn("the specified user's auth provider is not oauth", "authmodule", authInfo.AuthModule, "userid", authInfo.UserId) @@ -154,8 +153,8 @@ func checkOAuthRefreshToken(authInfo *models.UserAuth) error { } // InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero -func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr *models.UserAuth) error { - return o.AuthInfoService.UpdateAuthInfo(ctx, &models.UpdateAuthInfoCommand{ +func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth) error { + return o.AuthInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{ UserId: usr.UserId, AuthModule: usr.AuthModule, AuthId: usr.AuthId, @@ -167,7 +166,7 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr *models.UserAut }) } -func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *models.UserAuth) (*oauth2.Token, error) { +func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *login.UserAuth) (*oauth2.Token, error) { if err := checkOAuthRefreshToken(usr); err != nil { return nil, err } @@ -198,7 +197,7 @@ func (o *Service) tryGetOrRefreshAccessToken(ctx context.Context, usr *models.Us // If the tokens are not the same, update the entry in the DB if !tokensEq(persistedToken, token) { - updateAuthCommand := &models.UpdateAuthInfoCommand{ + updateAuthCommand := &login.UpdateAuthInfoCommand{ UserId: usr.UserId, AuthModule: usr.AuthModule, AuthId: usr.AuthId, diff --git a/pkg/services/oauthtoken/oauth_token_test.go b/pkg/services/oauthtoken/oauth_token_test.go index 063de3ce3f2..1392d1e229e 100644 --- a/pkg/services/oauthtoken/oauth_token_test.go +++ b/pkg/services/oauthtoken/oauth_token_test.go @@ -8,29 +8,29 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/login/social" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/login/authinfoservice" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "golang.org/x/oauth2" "golang.org/x/sync/singleflight" + + "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/login/authinfoservice" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" ) func TestService_HasOAuthEntry(t *testing.T) { testCases := []struct { name string user *user.SignedInUser - want *models.UserAuth + want *login.UserAuth wantExist bool wantErr bool err error getAuthInfoErr error - getAuthInfoUser models.UserAuth + getAuthInfoUser login.UserAuth }{ { name: "returns false without an error in case user is nil", @@ -61,15 +61,15 @@ func TestService_HasOAuthEntry(t *testing.T) { want: nil, wantExist: false, wantErr: false, - getAuthInfoUser: models.UserAuth{AuthModule: "auth_saml"}, + getAuthInfoUser: login.UserAuth{AuthModule: "auth_saml"}, }, { name: "returns true when the auth entry is found", user: &user.SignedInUser{}, - want: &models.UserAuth{AuthModule: "oauth_generic_oauth"}, + want: &login.UserAuth{AuthModule: "oauth_generic_oauth"}, wantExist: true, wantErr: false, - getAuthInfoUser: models.UserAuth{AuthModule: "oauth_generic_oauth"}, + getAuthInfoUser: login.UserAuth{AuthModule: "oauth_generic_oauth"}, }, } for _, tc := range testCases { @@ -101,7 +101,7 @@ func TestService_TryTokenRefresh_ValidToken(t *testing.T) { Expiry: time.Now(), TokenType: "Bearer", } - usr := &models.UserAuth{ + usr := &login.UserAuth{ AuthModule: "oauth_generic_oauth", OAuthAccessToken: token.AccessToken, OAuthRefreshToken: token.RefreshToken, @@ -117,7 +117,7 @@ func TestService_TryTokenRefresh_ValidToken(t *testing.T) { assert.Nil(t, err) socialConnector.AssertNumberOfCalls(t, "TokenSource", 1) - authInfoQuery := &models.GetAuthInfoQuery{} + authInfoQuery := &login.GetAuthInfoQuery{} err = srv.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) assert.Nil(t, err) @@ -139,7 +139,7 @@ func TestService_TryTokenRefresh_NoRefreshToken(t *testing.T) { Expiry: time.Now().Add(-time.Hour), TokenType: "Bearer", } - usr := &models.UserAuth{ + usr := &login.UserAuth{ AuthModule: "oauth_generic_oauth", OAuthAccessToken: token.AccessToken, OAuthRefreshToken: token.RefreshToken, @@ -174,7 +174,7 @@ func TestService_TryTokenRefresh_ExpiredToken(t *testing.T) { TokenType: "Bearer", } - usr := &models.UserAuth{ + usr := &login.UserAuth{ AuthModule: "oauth_generic_oauth", OAuthAccessToken: token.AccessToken, OAuthRefreshToken: token.RefreshToken, @@ -191,7 +191,7 @@ func TestService_TryTokenRefresh_ExpiredToken(t *testing.T) { assert.Nil(t, err) socialConnector.AssertNumberOfCalls(t, "TokenSource", 1) - authInfoQuery := &models.GetAuthInfoQuery{} + authInfoQuery := &login.GetAuthInfoQuery{} err = srv.AuthInfoService.GetAuthInfo(ctx, authInfoQuery) assert.Nil(t, err) @@ -207,7 +207,7 @@ func TestService_TryTokenRefresh_DifferentAuthModuleForUser(t *testing.T) { srv, _, socialConnector := setupOAuthTokenService(t) ctx := context.Background() token := &oauth2.Token{} - usr := &models.UserAuth{ + usr := &login.UserAuth{ AuthModule: "auth.saml", } @@ -307,30 +307,30 @@ type FakeAuthInfoStore struct { login.Store ExpectedError error ExpectedUser *user.User - ExpectedOAuth *models.UserAuth + ExpectedOAuth *login.UserAuth ExpectedDuplicateUserEntries int ExpectedHasDuplicateUserEntries int ExpectedLoginStats login.LoginStats } -func (f *FakeAuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { +func (f *FakeAuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *login.GetExternalUserInfoByLoginQuery) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { +func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) error { query.Result = f.ExpectedOAuth return f.ExpectedError } -func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { +func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *models.UserAuth) error { +func (f *FakeAuthInfoStore) UpdateAuthInfoDate(ctx context.Context, authInfo *login.UserAuth) error { return f.ExpectedError } -func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { +func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { f.ExpectedOAuth.OAuthAccessToken = cmd.OAuthToken.AccessToken f.ExpectedOAuth.OAuthExpiry = cmd.OAuthToken.Expiry f.ExpectedOAuth.OAuthTokenType = cmd.OAuthToken.TokenType @@ -338,7 +338,7 @@ func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *models.Upda return f.ExpectedError } -func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAuthInfoCommand) error { +func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { return f.ExpectedError } diff --git a/pkg/services/oauthtoken/oauthtokentest/mock.go b/pkg/services/oauthtoken/oauthtokentest/mock.go index 95bc1ccd204..e1af309faa3 100644 --- a/pkg/services/oauthtoken/oauthtokentest/mock.go +++ b/pkg/services/oauthtoken/oauthtokentest/mock.go @@ -3,18 +3,19 @@ package oauthtokentest import ( "context" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/user" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/user" ) type MockOauthTokenService struct { GetCurrentOauthTokenFunc func(ctx context.Context, usr *user.SignedInUser) *oauth2.Token IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool - HasOAuthEntryFunc func(ctx context.Context, usr *user.SignedInUser) (*models.UserAuth, bool, error) - InvalidateOAuthTokensFunc func(ctx context.Context, usr *models.UserAuth) error - TryTokenRefreshFunc func(ctx context.Context, usr *models.UserAuth) error + HasOAuthEntryFunc func(ctx context.Context, usr *user.SignedInUser) (*login.UserAuth, bool, error) + InvalidateOAuthTokensFunc func(ctx context.Context, usr *login.UserAuth) error + TryTokenRefreshFunc func(ctx context.Context, usr *login.UserAuth) error } func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr *user.SignedInUser) *oauth2.Token { @@ -31,21 +32,21 @@ func (m *MockOauthTokenService) IsOAuthPassThruEnabled(ds *datasources.DataSourc return false } -func (m *MockOauthTokenService) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*models.UserAuth, bool, error) { +func (m *MockOauthTokenService) HasOAuthEntry(ctx context.Context, usr *user.SignedInUser) (*login.UserAuth, bool, error) { if m.HasOAuthEntryFunc != nil { return m.HasOAuthEntryFunc(ctx, usr) } return nil, false, nil } -func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *models.UserAuth) error { +func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr *login.UserAuth) error { if m.InvalidateOAuthTokensFunc != nil { return m.InvalidateOAuthTokensFunc(ctx, usr) } return nil } -func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr *models.UserAuth) error { +func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr *login.UserAuth) error { if m.TryTokenRefreshFunc != nil { return m.TryTokenRefreshFunc(ctx, usr) } diff --git a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go index 230b2d7e290..2bb990c8446 100644 --- a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go +++ b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go @@ -3,11 +3,12 @@ package oauthtokentest import ( "context" - "github.com/grafana/grafana/pkg/models" + "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/user" - "golang.org/x/oauth2" ) // Service an OAuth token service suitable for tests. @@ -28,14 +29,14 @@ func (s *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool { return oauthtoken.IsOAuthPassThruEnabled(ds) } -func (s *Service) HasOAuthEntry(context.Context, *user.SignedInUser) (*models.UserAuth, bool, error) { +func (s *Service) HasOAuthEntry(context.Context, *user.SignedInUser) (*login.UserAuth, bool, error) { return nil, false, nil } -func (s *Service) TryTokenRefresh(context.Context, *models.UserAuth) error { +func (s *Service) TryTokenRefresh(context.Context, *login.UserAuth) error { return nil } -func (s *Service) InvalidateOAuthTokens(context.Context, *models.UserAuth) error { +func (s *Service) InvalidateOAuthTokens(context.Context, *login.UserAuth) error { return nil } diff --git a/pkg/services/playlist/playlistimpl/entity_store.go b/pkg/services/playlist/playlistimpl/entity_store.go index 068302975dc..d341994d817 100644 --- a/pkg/services/playlist/playlistimpl/entity_store.go +++ b/pkg/services/playlist/playlistimpl/entity_store.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store/entity" @@ -59,7 +58,7 @@ func (s *entityStoreImpl) sync() { GRN: &entity.GRN{ TenantId: info.OrgID, UID: info.UID, - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, }, Body: body, }) @@ -75,7 +74,7 @@ func (s *entityStoreImpl) Create(ctx context.Context, cmd *playlist.CreatePlayli } _, err = s.store.Write(ctx, &entity.WriteEntityRequest{ GRN: &entity.GRN{ - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, UID: rsp.UID, }, Body: body, @@ -97,7 +96,7 @@ func (s *entityStoreImpl) Update(ctx context.Context, cmd *playlist.UpdatePlayli _, err = s.store.Write(ctx, &entity.WriteEntityRequest{ GRN: &entity.GRN{ UID: rsp.Uid, - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, }, Body: body, }) @@ -114,7 +113,7 @@ func (s *entityStoreImpl) Delete(ctx context.Context, cmd *playlist.DeletePlayli _, err = s.store.Delete(ctx, &entity.DeleteEntityRequest{ GRN: &entity.GRN{ UID: cmd.UID, - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, }, }) if err != nil { @@ -145,7 +144,7 @@ func (s *entityStoreImpl) Get(ctx context.Context, q *playlist.GetPlaylistByUidQ rsp, err := s.store.Read(ctx, &entity.ReadEntityRequest{ GRN: &entity.GRN{ UID: q.UID, - Kind: models.StandardKindPlaylist, + Kind: entity.StandardKindPlaylist, }, WithBody: true, }) @@ -166,7 +165,7 @@ func (s *entityStoreImpl) Search(ctx context.Context, q *playlist.GetPlaylistsQu playlists := make(playlist.Playlists, 0) rsp, err := s.store.Search(ctx, &entity.EntitySearchRequest{ - Kind: []string{models.StandardKindPlaylist}, + Kind: []string{entity.StandardKindPlaylist}, WithBody: true, Limit: 1000, }) diff --git a/pkg/services/plugindashboards/service/dashboard_updater.go b/pkg/services/plugindashboards/service/dashboard_updater.go index 11a76ee07fb..03cd21907f4 100644 --- a/pkg/services/plugindashboards/service/dashboard_updater.go +++ b/pkg/services/plugindashboards/service/dashboard_updater.go @@ -143,11 +143,12 @@ func (du *DashboardUpdater) handlePluginStateChanged(ctx context.Context, event du.syncPluginDashboards(ctx, p, event.OrgId) } else { query := dashboards.GetDashboardsByPluginIDQuery{PluginID: event.PluginId, OrgID: event.OrgId} - if err := du.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil { + queryResult, err := du.dashboardPluginService.GetDashboardsByPluginID(ctx, &query) + if err != nil { return err } - for _, dash := range query.Result { + for _, dash := range queryResult { du.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug) if err := du.dashboardService.DeleteDashboard(ctx, dash.ID, dash.OrgID); err != nil { return err diff --git a/pkg/services/plugindashboards/service/service.go b/pkg/services/plugindashboards/service/service.go index 53e8b8e7288..48dd814bd20 100644 --- a/pkg/services/plugindashboards/service/service.go +++ b/pkg/services/plugindashboards/service/service.go @@ -42,7 +42,8 @@ func (s Service) ListPluginDashboards(ctx context.Context, req *plugindashboards // load current dashboards query := dashboards.GetDashboardsByPluginIDQuery{OrgID: req.OrgID, PluginID: req.PluginID} - if err := s.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil { + queryResult, err := s.dashboardPluginService.GetDashboardsByPluginID(ctx, &query) + if err != nil { return nil, err } @@ -67,7 +68,7 @@ func (s Service) ListPluginDashboards(ctx context.Context, req *plugindashboards res.Revision = dashboard.Data.Get("revision").MustInt64(1) // find existing dashboard - for _, existingDash := range query.Result { + for _, existingDash := range queryResult { if existingDash.Slug == dashboard.Slug { res.UID = existingDash.UID res.DashboardId = existingDash.ID @@ -84,7 +85,7 @@ func (s Service) ListPluginDashboards(ctx context.Context, req *plugindashboards } // find deleted dashboards - for _, dash := range query.Result { + for _, dash := range queryResult { if _, exists := existingMatches[dash.ID]; !exists { result = append(result, &plugindashboards.PluginDashboard{ UID: dash.UID, diff --git a/pkg/services/plugindashboards/service/service_test.go b/pkg/services/plugindashboards/service/service_test.go index 9765006fb6e..2349602ebdc 100644 --- a/pkg/services/plugindashboards/service/service_test.go +++ b/pkg/services/plugindashboards/service/service_test.go @@ -207,11 +207,11 @@ type dashboardPluginServiceMock struct { args []*dashmodels.GetDashboardsByPluginIDQuery } -func (d *dashboardPluginServiceMock) GetDashboardsByPluginID(ctx context.Context, query *dashmodels.GetDashboardsByPluginIDQuery) error { - query.Result = []*dashmodels.Dashboard{} +func (d *dashboardPluginServiceMock) GetDashboardsByPluginID(ctx context.Context, query *dashmodels.GetDashboardsByPluginIDQuery) ([]*dashmodels.Dashboard, error) { + queryResult := []*dashmodels.Dashboard{} if dashboards, exists := d.pluginDashboards[query.PluginID]; exists { - query.Result = dashboards + queryResult = dashboards } if d.args == nil { @@ -220,5 +220,5 @@ func (d *dashboardPluginServiceMock) GetDashboardsByPluginID(ctx context.Context d.args = append(d.args, query) - return nil + return queryResult, nil } diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index f6433487d20..ba9937438ab 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -10,11 +10,13 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/plugins/manager/loader" + "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/store" "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" @@ -34,6 +36,8 @@ var WireSet = wire.NewSet( process.ProvideService, wire.Bind(new(process.Service), new(*process.Manager)), coreplugin.ProvideCoreRegistry, + pluginscdn.ProvideService, + assetpath.ProvideService, loader.ProvideService, wire.Bind(new(loader.Service), new(*loader.Loader)), wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)), diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go index 9bb6a2ef22f..7505255a3bd 100644 --- a/pkg/services/preference/model.go +++ b/pkg/services/preference/model.go @@ -50,7 +50,6 @@ type SavePreferenceCommand struct { WeekStart string `json:"weekStart,omitempty"` Theme string `json:"theme,omitempty"` Language string `json:"language,omitempty"` - Navbar *NavbarPreference `json:"navbar,omitempty"` QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } @@ -65,24 +64,11 @@ type PatchPreferenceCommand struct { WeekStart *string `json:"weekStart,omitempty"` Theme *string `json:"theme,omitempty"` Language *string `json:"language,omitempty"` - Navbar *NavbarPreference `json:"navbar,omitempty"` QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } -type NavLink struct { - ID string `json:"id,omitempty"` - Text string `json:"text,omitempty"` - Url string `json:"url,omitempty"` - Target string `json:"target,omitempty"` -} - -type NavbarPreference struct { - SavedItems []NavLink `json:"savedItems"` -} - type PreferenceJSONData struct { 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 feadcbfa06e..1ddc5b94cf0 100644 --- a/pkg/services/preference/prefimpl/pref.go +++ b/pkg/services/preference/prefimpl/pref.go @@ -65,10 +65,6 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference res.JSONData.Language = p.JSONData.Language } - if len(p.JSONData.Navbar.SavedItems) > 0 { - res.JSONData.Navbar = p.JSONData.Navbar - } - if p.JSONData.QueryHistory.HomeTab != "" { res.JSONData.QueryHistory.HomeTab = p.JSONData.QueryHistory.HomeTab } @@ -134,9 +130,6 @@ func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) err Language: cmd.Language, } - if cmd.Navbar != nil { - preference.JSONData.Navbar = *cmd.Navbar - } if cmd.QueryHistory != nil { preference.JSONData.QueryHistory = *cmd.QueryHistory } @@ -173,15 +166,6 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e preference.JSONData.Language = *cmd.Language } - if cmd.Navbar != nil { - if preference.JSONData == nil { - preference.JSONData = &pref.PreferenceJSONData{} - } - if cmd.Navbar.SavedItems != nil { - preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems - } - } - if cmd.QueryHistory != nil { if preference.JSONData == nil { preference.JSONData = &pref.PreferenceJSONData{} @@ -210,16 +194,6 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e preference.Updated = time.Now() preference.Version += 1 - // Wrap this in an if statement to maintain backwards compatibility - if cmd.Navbar != nil { - if preference.JSONData == nil { - preference.JSONData = &pref.PreferenceJSONData{} - } - if cmd.Navbar.SavedItems != nil { - preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems - } - } - if exists { err = s.store.Update(ctx, preference) } else { diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go index 0f5868b0117..9463901ce7f 100644 --- a/pkg/services/preference/prefimpl/pref_test.go +++ b/pkg/services/preference/prefimpl/pref_test.go @@ -174,51 +174,15 @@ func TestGetDefaults_JSONData(t *testing.T) { queryPreference := pref.QueryHistoryPreference{ HomeTab: "hometab", } - userNavbarPreferences := pref.NavbarPreference{ - SavedItems: []pref.NavLink{{ - ID: "explore", - Text: "Explore", - Url: "/explore", - }}, - } - orgNavbarPreferences := pref.NavbarPreference{ - SavedItems: []pref.NavLink{{ - ID: "alerting", - Text: "Alerting", - Url: "/alerting", - }}, - } - team1NavbarPreferences := pref.NavbarPreference{ - SavedItems: []pref.NavLink{{ - ID: "dashboards", - Text: "Dashboards", - Url: "/dashboards", - }}, - } - team2NavbarPreferences := pref.NavbarPreference{ - SavedItems: []pref.NavLink{{ - ID: "home", - Text: "Home", - Url: "/home", - }}, - } userPreferencesJsonData := pref.PreferenceJSONData{ - Navbar: userNavbarPreferences, QueryHistory: queryPreference, } - orgPreferencesJsonData := pref.PreferenceJSONData{ - Navbar: orgNavbarPreferences, - } + orgPreferencesJsonData := pref.PreferenceJSONData{} orgPreferencesWithLanguageJsonData := pref.PreferenceJSONData{ - Navbar: orgNavbarPreferences, Language: "en-GB", } - team2PreferencesJsonData := pref.PreferenceJSONData{ - Navbar: team2NavbarPreferences, - } - team1PreferencesJsonData := pref.PreferenceJSONData{ - Navbar: team1NavbarPreferences, - } + team2PreferencesJsonData := pref.PreferenceJSONData{} + team1PreferencesJsonData := pref.PreferenceJSONData{} t.Run("users have precedence over org", func(t *testing.T) { prefService := &Service{ @@ -274,7 +238,6 @@ func TestGetDefaults_JSONData(t *testing.T) { WeekStart: &weekStart, JSONData: &pref.PreferenceJSONData{ Language: "en-GB", - Navbar: userNavbarPreferences, QueryHistory: queryPreference, }, }, preference) diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 0773c1c7134..43d56d4c97f 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -20,13 +20,6 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { weekStartOne := "1" ss := db.InitTestDB(t) prefStore := fn(ss) - orgNavbarPreferences := pref.NavbarPreference{ - SavedItems: []pref.NavLink{{ - ID: "alerting", - Text: "Alerting", - Url: "/alerting", - }}, - } t.Run("Get with saved org and user home dashboard returns not found", func(t *testing.T) { query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2} @@ -124,7 +117,7 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { Timezone: "browser", HomeDashboardID: 5, WeekStart: &weekStartOne, - JSONData: &pref.PreferenceJSONData{Navbar: orgNavbarPreferences}, + JSONData: &pref.PreferenceJSONData{}, Created: time.Now(), Updated: time.Now(), }) diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/file/rules_types.go similarity index 50% rename from pkg/services/provisioning/alerting/rules_types.go rename to pkg/services/provisioning/alerting/file/rules_types.go index b300d918c46..3c726dffc41 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/file/rules_types.go @@ -1,4 +1,4 @@ -package alerting +package file import ( "encoding/json" @@ -31,11 +31,11 @@ type AlertRuleGroupV1 struct { Rules []AlertRuleV1 `json:"rules" yaml:"rules"` } -func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (AlertRuleGroup, error) { - ruleGroup := AlertRuleGroup{} - ruleGroup.Name = ruleGroupV1.Name.Value() - if strings.TrimSpace(ruleGroup.Name) == "" { - return AlertRuleGroup{}, errors.New("rule group has no name set") +func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (AlertRuleGroupWithFolderTitle, error) { + ruleGroup := AlertRuleGroupWithFolderTitle{AlertRuleGroup: &models.AlertRuleGroup{}} + ruleGroup.Title = ruleGroupV1.Name.Value() + if strings.TrimSpace(ruleGroup.Title) == "" { + return AlertRuleGroupWithFolderTitle{}, errors.New("rule group has no name set") } ruleGroup.OrgID = ruleGroupV1.OrgID.Value() if ruleGroup.OrgID < 1 { @@ -43,29 +43,27 @@ func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (AlertRuleGroup, error) { } interval, err := model.ParseDuration(ruleGroupV1.Interval.Value()) if err != nil { - return AlertRuleGroup{}, err + return AlertRuleGroupWithFolderTitle{}, err } - 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") + ruleGroup.Interval = int64(time.Duration(interval).Seconds()) + ruleGroup.FolderTitle = ruleGroupV1.Folder.Value() + if strings.TrimSpace(ruleGroup.FolderTitle) == "" { + return AlertRuleGroupWithFolderTitle{}, errors.New("rule group has no folder set") } for _, ruleV1 := range ruleGroupV1.Rules { rule, err := ruleV1.mapToModel(ruleGroup.OrgID) if err != nil { - return AlertRuleGroup{}, err + return AlertRuleGroupWithFolderTitle{}, err } ruleGroup.Rules = append(ruleGroup.Rules, rule) } return ruleGroup, nil } -type AlertRuleGroup struct { - OrgID int64 - Name string - Folder string - Interval time.Duration - Rules []models.AlertRule +type AlertRuleGroupWithFolderTitle struct { + *models.AlertRuleGroup + OrgID int64 + FolderTitle string } type AlertRuleV1 struct { @@ -175,3 +173,130 @@ func (queryV1 *QueryV1) mapToModel() (models.AlertQuery, error) { Model: rawMessage, }, nil } + +// Response structs + +// AlertingFileExport is the full provisioned file export. +// swagger:model +type AlertingFileExport struct { + APIVersion int64 `json:"apiVersion" yaml:"apiVersion"` + Groups []AlertRuleGroupExport `json:"groups" yaml:"groups"` +} + +// AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1. +type AlertRuleGroupExport struct { + OrgID int64 `json:"orgId" yaml:"orgId"` + Name string `json:"name" yaml:"name"` + Folder string `json:"folder" yaml:"folder"` + Interval model.Duration `json:"interval" yaml:"interval"` + Rules []AlertRuleExport `json:"rules" yaml:"rules"` +} + +// AlertRuleExport is the provisioned file export of models.AlertRule. +type AlertRuleExport struct { + UID string `json:"uid" yaml:"uid"` + Title string `json:"title" yaml:"title"` + Condition string `json:"condition" yaml:"condition"` + Data []AlertQueryExport `json:"data" yaml:"data"` + DashboardUID string `json:"dasboardUid,omitempty" yaml:"dashboardUid,omitempty"` + PanelID int64 `json:"panelId,omitempty" yaml:"panelId,omitempty"` + NoDataState models.NoDataState `json:"noDataState" yaml:"noDataState"` + ExecErrState models.ExecutionErrorState `json:"execErrState" yaml:"execErrState"` + For model.Duration `json:"for" yaml:"for"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` +} + +// AlertQueryExport is the provisioned export of models.AlertQuery. +type AlertQueryExport struct { + RefID string `json:"refId" yaml:"refId"` + QueryType string `json:"queryType,omitempty" yaml:"queryType,omitempty"` + RelativeTimeRange models.RelativeTimeRange `json:"relativeTimeRange,omitempty" yaml:"relativeTimeRange,omitempty"` + DatasourceUID string `json:"datasourceUid" yaml:"datasourceUid"` + Model map[string]interface{} `json:"model" yaml:"model"` +} + +// NewAlertingFileExport creates an AlertingFileExport DTO from []AlertRuleGroupWithFolderTitle. +func NewAlertingFileExport(groups []AlertRuleGroupWithFolderTitle) (AlertingFileExport, error) { + f := AlertingFileExport{APIVersion: 1} + for _, group := range groups { + export, err := newAlertRuleGroupExport(group) + if err != nil { + return AlertingFileExport{}, err + } + f.Groups = append(f.Groups, export) + } + return f, nil +} + +// newAlertRuleGroupExport creates a AlertRuleGroupExport DTO from models.AlertRuleGroup. +func newAlertRuleGroupExport(d AlertRuleGroupWithFolderTitle) (AlertRuleGroupExport, error) { + rules := make([]AlertRuleExport, 0, len(d.Rules)) + for i := range d.Rules { + alert, err := newAlertRuleExport(d.Rules[i]) + if err != nil { + return AlertRuleGroupExport{}, err + } + rules = append(rules, alert) + } + return AlertRuleGroupExport{ + OrgID: d.OrgID, + Name: d.Title, + Folder: d.FolderTitle, + Interval: model.Duration(time.Duration(d.Interval) * time.Second), + Rules: rules, + }, nil +} + +// newAlertRuleExport creates a AlertRuleExport DTO from models.AlertRule. +func newAlertRuleExport(rule models.AlertRule) (AlertRuleExport, error) { + data := make([]AlertQueryExport, 0, len(rule.Data)) + for i := range rule.Data { + query, err := newAlertQueryExport(rule.Data[i]) + if err != nil { + return AlertRuleExport{}, err + } + data = append(data, query) + } + + var dashboardUID string + if rule.DashboardUID != nil { + dashboardUID = *rule.DashboardUID + } + + var panelID int64 + if rule.PanelID != nil { + panelID = *rule.PanelID + } + + return AlertRuleExport{ + UID: rule.UID, + Title: rule.Title, + For: model.Duration(rule.For), + Condition: rule.Condition, + Data: data, + DashboardUID: dashboardUID, + PanelID: panelID, + NoDataState: rule.NoDataState, + ExecErrState: rule.ExecErrState, + Annotations: rule.Annotations, + Labels: rule.Labels, + }, nil +} + +// newAlertQueryExport creates a AlertQueryExport DTO from models.AlertQuery. +func newAlertQueryExport(query models.AlertQuery) (AlertQueryExport, error) { + // We unmarshal the json.RawMessage model into a map in order to facilitate yaml marshalling. + var mdl map[string]interface{} + err := json.Unmarshal(query.Model, &mdl) + if err != nil { + return AlertQueryExport{}, err + } + return AlertQueryExport{ + RefID: query.RefID, + QueryType: query.QueryType, + RelativeTimeRange: query.RelativeTimeRange, + DatasourceUID: query.DatasourceUID, + Model: mdl, + }, nil +} diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/file/rules_types_test.go similarity index 98% rename from pkg/services/provisioning/alerting/rules_types_test.go rename to pkg/services/provisioning/alerting/file/rules_types_test.go index 0eac93e2404..abc2836f01a 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/file/rules_types_test.go @@ -1,13 +1,14 @@ -package alerting +package file import ( "testing" "time" - "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/provisioning/values" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" + + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/provisioning/values" ) func TestRuleGroup(t *testing.T) { @@ -60,7 +61,7 @@ func TestRuleGroup(t *testing.T) { rg.Interval = interval rgMapped, err := rg.MapToModel() require.NoError(t, err) - require.Equal(t, 48*time.Hour, rgMapped.Interval) + require.Equal(t, int64(48*time.Hour/time.Second), rgMapped.Interval) }) t.Run("a rule group with an empty org id should default to 1", func(t *testing.T) { rg := validRuleGroupV1(t) diff --git a/pkg/services/provisioning/alerting/rules_provisioner.go b/pkg/services/provisioning/alerting/rules_provisioner.go index 5d816b325be..36fdbef76ea 100644 --- a/pkg/services/provisioning/alerting/rules_provisioner.go +++ b/pkg/services/provisioning/alerting/rules_provisioner.go @@ -41,24 +41,24 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, files []*AlertingFile) error { for _, file := range files { for _, group := range file.Groups { - folderUID, err := prov.getOrCreateFolderUID(ctx, group.Folder, group.OrgID) + folderUID, err := prov.getOrCreateFolderUID(ctx, group.FolderTitle, group.OrgID) if err != nil { return err } prov.logger.Debug("provisioning alert rule group", "org", group.OrgID, - "folder", group.Folder, + "folder", group.FolderTitle, "folderUID", folderUID, - "name", group.Name) + "name", group.Title) for _, rule := range group.Rules { rule.NamespaceUID = folderUID - rule.RuleGroup = group.Name - err = prov.provisionRule(ctx, group.OrgID, rule, group.Folder, folderUID) + rule.RuleGroup = group.Title + err = prov.provisionRule(ctx, group.OrgID, rule) if err != nil { return err } } - err = prov.ruleService.UpdateRuleGroup(ctx, group.OrgID, folderUID, group.Name, int64(group.Interval.Seconds())) + err = prov.ruleService.UpdateRuleGroup(ctx, group.OrgID, folderUID, group.Title, group.Interval) if err != nil { return err } @@ -77,9 +77,7 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, func (prov *defaultAlertRuleProvisioner) provisionRule( ctx context.Context, orgID int64, - rule alert_models.AlertRule, - folder, - folderUID string) error { + rule alert_models.AlertRule) error { prov.logger.Debug("provisioning alert rule", "uid", rule.UID, "org", rule.OrgID) _, _, err := prov.ruleService.GetAlertRule(ctx, orgID, rule.UID) if err != nil && !errors.Is(err, alert_models.ErrAlertRuleNotFound) { @@ -102,7 +100,7 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( Slug: slugify.Slugify(folderName), OrgID: orgID, } - err := prov.dashboardService.GetDashboard(ctx, cmd) + cmdResult, err := prov.dashboardService.GetDashboard(ctx, cmd) if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { return "", err } @@ -123,9 +121,9 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( return dbDash.UID, nil } - if !cmd.Result.IsFolder { + if !cmdResult.IsFolder { return "", fmt.Errorf("got invalid response. expected folder, found dashboard") } - return cmd.Result.UID, nil + return cmdResult.UID, nil } diff --git a/pkg/services/provisioning/alerting/types.go b/pkg/services/provisioning/alerting/types.go index 6b500c85dd1..6a3331c095b 100644 --- a/pkg/services/provisioning/alerting/types.go +++ b/pkg/services/provisioning/alerting/types.go @@ -3,6 +3,7 @@ package alerting import ( "fmt" + "github.com/grafana/grafana/pkg/services/provisioning/alerting/file" "github.com/grafana/grafana/pkg/services/provisioning/values" ) @@ -15,8 +16,8 @@ type OrgID int64 type AlertingFile struct { configVersion Filename string - Groups []AlertRuleGroup - DeleteRules []RuleDelete + Groups []file.AlertRuleGroupWithFolderTitle + DeleteRules []file.RuleDelete ContactPoints []ContactPoint DeleteContactPoints []DeleteContactPoint Policies []NotificiationPolicy @@ -30,8 +31,8 @@ type AlertingFile struct { type AlertingFileV1 struct { configVersion Filename string - Groups []AlertRuleGroupV1 `json:"groups" yaml:"groups"` - DeleteRules []RuleDeleteV1 `json:"deleteRules" yaml:"deleteRules"` + Groups []file.AlertRuleGroupV1 `json:"groups" yaml:"groups"` + DeleteRules []file.RuleDeleteV1 `json:"deleteRules" yaml:"deleteRules"` ContactPoints []ContactPointV1 `json:"contactPoints" yaml:"contactPoints"` DeleteContactPoints []DeleteContactPointV1 `json:"deleteContactPoints" yaml:"deleteContactPoints"` Policies []NotificiationPolicyV1 `json:"policies" yaml:"policies"` @@ -132,7 +133,7 @@ func (fileV1 *AlertingFileV1) mapRules(alertingFile *AlertingFile) error { if orgID < 1 { orgID = 1 } - ruleDelete := RuleDelete{ + ruleDelete := file.RuleDelete{ UID: ruleDeleteV1.UID.Value(), OrgID: orgID, } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 8448a431620..b2900b696d3 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -300,7 +300,7 @@ func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, serv } cmd := &dashboards.GetDashboardQuery{Slug: slugify.Slugify(folderName), OrgID: cfg.OrgID} - err := fr.dashboardStore.GetDashboard(ctx, cmd) + result, err := fr.dashboardStore.GetDashboard(ctx, cmd) if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { return 0, err @@ -326,11 +326,11 @@ func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, serv return dbDash.ID, nil } - if !cmd.Result.IsFolder { + if !result.IsFolder { return 0, fmt.Errorf("got invalid response. expected folder, found dashboard") } - return cmd.Result.ID, nil + return result.ID, nil } func resolveSymlink(fileinfo os.FileInfo, path string) (os.FileInfo, error) { diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index d7a4649c888..df6fc66ade9 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -512,6 +512,6 @@ func (ffi FakeFileInfo) Sys() interface{} { type fakeDashboardStore struct{} -func (fds *fakeDashboardStore) GetDashboard(_ context.Context, _ *dashboards.GetDashboardQuery) error { - return dashboards.ErrDashboardNotFound +func (fds *fakeDashboardStore) GetDashboard(_ context.Context, _ *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { + return nil, dashboards.ErrDashboardNotFound } diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index fe6f7c727fa..9f7bff61917 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -269,6 +269,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error ruleService := provisioning.NewAlertRuleService( st, st, + ps.dashboardService, ps.quotaService, ps.SQLStore, int64(ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go index 40153769f6f..05e649aa586 100644 --- a/pkg/services/provisioning/utils/utils.go +++ b/pkg/services/provisioning/utils/utils.go @@ -10,7 +10,7 @@ import ( ) type DashboardStore interface { - GetDashboard(context.Context, *dashboards.GetDashboardQuery) error + GetDashboard(context.Context, *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) } func CheckOrgExists(ctx context.Context, orgService org.Service, orgID int64) error { diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index 1837911e62d..8ac11743ed6 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -8,13 +8,13 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/web" ) @@ -89,7 +89,7 @@ func (api *Api) RegisterAPIEndpoints() { // ListPublicDashboards Gets list of public dashboards by orgId // GET /api/dashboards/public-dashboards -func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { +func (api *Api) ListPublicDashboards(c *contextmodel.ReqContext) response.Response { resp, err := api.PublicDashboardService.FindAll(c.Req.Context(), c.SignedInUser, c.OrgID) if err != nil { return response.Err(err) @@ -99,10 +99,10 @@ func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { // GetPublicDashboard Gets public dashboard for dashboard // GET /api/dashboards/uid/:dashboardUid/public-dashboards -func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) GetPublicDashboard(c *contextmodel.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] - if !tokens.IsValidShortUID(dashboardUid) { + if !validation.IsValidShortUID(dashboardUid) { return response.Err(ErrPublicDashboardIdentifierNotSet.Errorf("GetPublicDashboard: no dashboard Uid for public dashboard specified")) } @@ -120,10 +120,10 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { // CreatePublicDashboard Sets public dashboard for dashboard // POST /api/dashboards/uid/:dashboardUid/public-dashboards -func (api *Api) CreatePublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) CreatePublicDashboard(c *contextmodel.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] - if !tokens.IsValidShortUID(dashboardUid) { + if !validation.IsValidShortUID(dashboardUid) { return response.Err(ErrInvalidUid.Errorf("CreatePublicDashboard: invalid Uid %s", dashboardUid)) } @@ -152,15 +152,15 @@ func (api *Api) CreatePublicDashboard(c *models.ReqContext) response.Response { // UpdatePublicDashboard Sets public dashboard for dashboard // PUT /api/dashboards/uid/:dashboardUid/public-dashboards/:uid -func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) UpdatePublicDashboard(c *contextmodel.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] - if !tokens.IsValidShortUID(dashboardUid) { + if !validation.IsValidShortUID(dashboardUid) { return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid dashboard Uid %s", dashboardUid)) } uid := web.Params(c.Req)[":uid"] - if !tokens.IsValidShortUID(uid) { + if !validation.IsValidShortUID(uid) { return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid Uid %s", uid)) } @@ -190,9 +190,9 @@ func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { // Delete a public dashboard // DELETE /api/dashboards/uid/:dashboardUid/public-dashboards/:uid -func (api *Api) DeletePublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) DeletePublicDashboard(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] - if !tokens.IsValidShortUID(uid) { + if !validation.IsValidShortUID(uid) { return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid Uid %s", uid)) } diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go index a2cf1bb6238..47a96d39363 100644 --- a/pkg/services/publicdashboards/api/common_test.go +++ b/pkg/services/publicdashboards/api/common_test.go @@ -15,12 +15,12 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" @@ -82,7 +82,7 @@ type testContext struct { func contextProvider(tc *testContext) web.Handler { return func(c *web.Context) { signedIn := tc.user != nil - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: c, SignedInUser: tc.user, IsSignedIn: signedIn, diff --git a/pkg/services/publicdashboards/api/middleware.go b/pkg/services/publicdashboards/api/middleware.go index 856bc2899ad..1805ae9c358 100644 --- a/pkg/services/publicdashboards/api/middleware.go +++ b/pkg/services/publicdashboards/api/middleware.go @@ -4,17 +4,17 @@ import ( "net/http" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" + "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/web" ) // SetPublicDashboardOrgIdOnContext Adds orgId to context based on org of public dashboard -func SetPublicDashboardOrgIdOnContext(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func SetPublicDashboardOrgIdOnContext(publicDashboardService publicdashboards.Service) func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { accessToken, ok := web.Params(c.Req)[":accessToken"] - if !ok || !tokens.IsValidAccessToken(accessToken) { + if !ok || !validation.IsValidAccessToken(accessToken) { return } @@ -29,15 +29,15 @@ func SetPublicDashboardOrgIdOnContext(publicDashboardService publicdashboards.Se } // SetPublicDashboardFlag Adds public dashboard flag on context -func SetPublicDashboardFlag(c *models.ReqContext) { +func SetPublicDashboardFlag(c *contextmodel.ReqContext) { c.IsPublicDashboardView = true } // RequiresExistingAccessToken Middleware to enforce that a public dashboards exists before continuing to handler. This // method will query the database to ensure that it exists. // Use when we want to enforce a public dashboard is valid on an endpoint we do not maintain -func RequiresExistingAccessToken(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func RequiresExistingAccessToken(publicDashboardService publicdashboards.Service) func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { accessToken, ok := web.Params(c.Req)[":accessToken"] if !ok { @@ -45,7 +45,7 @@ func RequiresExistingAccessToken(publicDashboardService publicdashboards.Service return } - if !tokens.IsValidAccessToken(accessToken) { + if !validation.IsValidAccessToken(accessToken) { c.JsonApiErr(http.StatusBadRequest, "Invalid access token", nil) } @@ -62,8 +62,8 @@ func RequiresExistingAccessToken(publicDashboardService publicdashboards.Service } } -func CountPublicDashboardRequest() func(c *models.ReqContext) { - return func(c *models.ReqContext) { +func CountPublicDashboardRequest() func(c *contextmodel.ReqContext) { + return func(c *contextmodel.ReqContext) { metrics.MPublicDashboardRequestCount.Inc() } } diff --git a/pkg/services/publicdashboards/api/middleware_test.go b/pkg/services/publicdashboards/api/middleware_test.go index 20680679ab5..3e6f9811705 100644 --- a/pkg/services/publicdashboards/api/middleware_test.go +++ b/pkg/services/publicdashboards/api/middleware_test.go @@ -8,9 +8,9 @@ import ( "errors" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" + "github.com/grafana/grafana/pkg/services/publicdashboards/service" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" @@ -18,7 +18,7 @@ import ( "github.com/stretchr/testify/require" ) -var validAccessToken, _ = tokens.GenerateAccessToken() +var validAccessToken, _ = service.GenerateAccessToken() func TestRequiresExistingAccessToken(t *testing.T) { tests := []struct { @@ -146,21 +146,21 @@ func TestSetPublicDashboardOrgIdOnContext(t *testing.T) { func TestSetPublicDashboardFlag(t *testing.T) { t.Run("Adds context.IsPublicDashboardView=true to request", func(t *testing.T) { - ctx := &models.ReqContext{} + ctx := &contextmodel.ReqContext{} SetPublicDashboardFlag(ctx) assert.True(t, ctx.IsPublicDashboardView) }) } // This is a helper to test middleware. It handles creating a -// proper models.ReqContext, setting web parameters, executing middleware, and +// proper contextmodel.ReqContext, setting web parameters, executing middleware, and // returning a response. Response will default to result of // httptest.NewRecorder() return value and will only change if modified by the // middlware as this will no accept a handler method -func runMw(t *testing.T, ctx *models.ReqContext, httpmethod string, path string, webparams map[string]string, mw func(c *models.ReqContext)) (*models.ReqContext, *httptest.ResponseRecorder) { +func runMw(t *testing.T, ctx *contextmodel.ReqContext, httpmethod string, path string, webparams map[string]string, mw func(c *contextmodel.ReqContext)) (*contextmodel.ReqContext, *httptest.ResponseRecorder) { // create valid request context and set 0 values if they don't exist if ctx == nil { - ctx = &models.ReqContext{} + ctx = &contextmodel.ReqContext{} } if ctx.Context == nil { ctx.Context = &web.Context{} diff --git a/pkg/services/publicdashboards/api/query.go b/pkg/services/publicdashboards/api/query.go index 2f9473ca9f0..4f4583d621f 100644 --- a/pkg/services/publicdashboards/api/query.go +++ b/pkg/services/publicdashboards/api/query.go @@ -6,18 +6,18 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/web" ) // ViewPublicDashboard Gets public dashboard // GET /api/public/dashboards/:accessToken -func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) ViewPublicDashboard(c *contextmodel.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] - if !tokens.IsValidAccessToken(accessToken) { + if !validation.IsValidAccessToken(accessToken) { return response.Err(ErrInvalidAccessToken.Errorf("ViewPublicDashboard: invalid access token")) } @@ -53,9 +53,9 @@ func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response { // QueryPublicDashboard returns all results for a given panel on a public dashboard // POST /api/public/dashboard/:accessToken/panels/:panelId/query -func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { +func (api *Api) QueryPublicDashboard(c *contextmodel.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] - if !tokens.IsValidAccessToken(accessToken) { + if !validation.IsValidAccessToken(accessToken) { return response.Err(ErrInvalidAccessToken.Errorf("QueryPublicDashboard: invalid access token")) } @@ -79,9 +79,9 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { // GetAnnotations returns annotations for a public dashboard // GET /api/public/dashboards/:accessToken/annotations -func (api *Api) GetAnnotations(c *models.ReqContext) response.Response { +func (api *Api) GetAnnotations(c *contextmodel.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] - if !tokens.IsValidAccessToken(accessToken) { + if !validation.IsValidAccessToken(accessToken) { return response.Err(ErrInvalidAccessToken.Errorf("GetAnnotations: invalid access token")) } diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index ba4a6eab5ad..d8da2b866f7 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -10,8 +10,8 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" @@ -693,7 +693,7 @@ func insertPublicDashboard(t *testing.T, publicdashboardStore *PublicDashboardSt uid := util.GenerateShortUID() - accessToken, err := tokens.GenerateAccessToken() + accessToken, err := service.GenerateAccessToken() require.NoError(t, err) cmd := SavePublicDashboardCommand{ diff --git a/pkg/services/publicdashboards/internal/tokens/tokens.go b/pkg/services/publicdashboards/internal/tokens/tokens.go deleted file mode 100644 index 4d4f3accf0d..00000000000 --- a/pkg/services/publicdashboards/internal/tokens/tokens.go +++ /dev/null @@ -1,29 +0,0 @@ -package tokens - -import ( - "fmt" - - "github.com/google/uuid" - "github.com/grafana/grafana/pkg/util" -) - -// GenerateAccessToken generates an uuid formatted without dashes to use as access token -func GenerateAccessToken() (string, error) { - token, err := uuid.NewRandom() - if err != nil { - return "", err - } - return fmt.Sprintf("%x", token[:]), nil -} - -// IsValidAccessToken asserts that an accessToken is a valid uuid -func IsValidAccessToken(token string) bool { - _, err := uuid.Parse(token) - return err == nil -} - -// IsValidShortUID checks that the uid is not blank and contains valid -// characters. Wraps utils.IsValidShortUID -func IsValidShortUID(uid string) bool { - return uid != "" && util.IsValidShortUID(uid) -} diff --git a/pkg/services/publicdashboards/internal/tokens/tokens_test.go b/pkg/services/publicdashboards/internal/tokens/tokens_test.go deleted file mode 100644 index b04ba221f10..00000000000 --- a/pkg/services/publicdashboards/internal/tokens/tokens_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package tokens - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateAccessToken(t *testing.T) { - accessToken, err := GenerateAccessToken() - - t.Run("length", func(t *testing.T) { - require.NoError(t, err) - assert.Equal(t, 32, len(accessToken)) - }) - - t.Run("no - ", func(t *testing.T) { - assert.False(t, strings.Contains("-", accessToken)) - }) -} - -func TestValidAccessToken(t *testing.T) { - t.Run("true", func(t *testing.T) { - uuid, _ := GenerateAccessToken() - assert.True(t, IsValidAccessToken(uuid)) - }) - - t.Run("false when blank", func(t *testing.T) { - assert.False(t, IsValidAccessToken("")) - }) - - t.Run("false when can't be parsed by uuid lib", func(t *testing.T) { - // too long - assert.False(t, IsValidAccessToken("0123456789012345678901234567890123456789")) - }) -} - -// we just check base cases since this wraps utils.IsValidShortUID which has -// test coverage -func TestValidUid(t *testing.T) { - t.Run("true", func(t *testing.T) { - assert.True(t, IsValidShortUID("afqrz7jZZ")) - }) - - t.Run("false when blank", func(t *testing.T) { - assert.False(t, IsValidShortUID("")) - }) - - t.Run("false when invalid chars", func(t *testing.T) { - assert.False(t, IsValidShortUID("afqrz7j%%")) - }) -} diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index 8274e6c8551..37d25bf0340 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -39,6 +39,7 @@ type PublicDashboard struct { AccessToken string `json:"accessToken" xorm:"access_token"` AnnotationsEnabled bool `json:"annotationsEnabled" xorm:"annotations_enabled"` TimeSelectionEnabled bool `json:"timeSelectionEnabled" xorm:"time_selection_enabled"` + Share string `json:"share"` CreatedBy int64 `json:"createdBy" xorm:"created_by"` UpdatedBy int64 `json:"updatedBy" xorm:"updated_by"` diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index 883c51e1b80..3fcf2bf2f87 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.16.0. DO NOT EDIT. +// Code generated by mockery v2.14.0. DO NOT EDIT. package publicdashboards @@ -102,6 +102,29 @@ func (_m *FakePublicDashboardService) ExistsEnabledByDashboardUid(ctx context.Co return r0, r1 } +// Find provides a mock function with given fields: ctx, uid +func (_m *FakePublicDashboardService) Find(ctx context.Context, uid string) (*models.PublicDashboard, error) { + ret := _m.Called(ctx, uid) + + var r0 *models.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, string) *models.PublicDashboard); ok { + r0 = rf(ctx, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.PublicDashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindAll provides a mock function with given fields: ctx, u, orgId func (_m *FakePublicDashboardService) FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]models.PublicDashboardListResponse, error) { ret := _m.Called(ctx, u, orgId) diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index b301e9be8ad..017c37f4152 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -21,6 +21,7 @@ type Service interface { FindAnnotations(ctx context.Context, reqDTO AnnotationsQueryDTO, accessToken string) ([]AnnotationEvent, error) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*dashboards.Dashboard, error) FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error) + Find(ctx context.Context, uid string) (*PublicDashboard, error) Create(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) Delete(ctx context.Context, orgId int64, uid string) error diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 812257d6850..2d557248e4e 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -3,15 +3,16 @@ package service import ( "context" "errors" + "fmt" "time" + "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/query" @@ -60,6 +61,14 @@ func ProvideService( } } +func (pd *PublicDashboardServiceImpl) Find(ctx context.Context, uid string) (*PublicDashboard, error) { + pubdash, err := pd.store.Find(ctx, uid) + if err != nil { + return nil, ErrInternalServerError.Errorf("Find: failed to find public dashboard%w", err) + } + return pubdash, nil +} + // FindDashboard Gets a dashboard by Uid func (pd *PublicDashboardServiceImpl) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*dashboards.Dashboard, error) { dash, err := pd.store.FindDashboard(ctx, orgId, dashboardUid) @@ -281,7 +290,7 @@ func (pd *PublicDashboardServiceImpl) NewPublicDashboardAccessToken(ctx context. var accessToken string for i := 0; i < 3; i++ { var err error - accessToken, err = tokens.GenerateAccessToken() + accessToken, err = GenerateAccessToken() if err != nil { continue } @@ -396,3 +405,12 @@ func publicDashboardIsEnabledChanged(existingPubdash *PublicDashboard, newPubdas isEnabledChanged := existingPubdash != nil && newPubdash.IsEnabled != existingPubdash.IsEnabled return newDashCreated || isEnabledChanged } + +// GenerateAccessToken generates an uuid formatted without dashes to use as access token +func GenerateAccessToken() (string, error) { + token, err := uuid.NewRandom() + if err != nil { + return "", err + } + return fmt.Sprintf("%x", token[:]), nil +} diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index ebc16b51d80..8a1acd1389b 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "testing" "time" @@ -18,8 +19,8 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" . "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/publicdashboards/database" - "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -914,7 +915,7 @@ func TestPublicDashboardServiceImpl_NewPublicDashboardAccessToken(t *testing.T) if err == nil { assert.NotEqual(t, got, tt.want, "NewPublicDashboardAccessToken(%v)", tt.args.ctx) - assert.True(t, tokens.IsValidAccessToken(got), "NewPublicDashboardAccessToken(%v)", tt.args.ctx) + assert.True(t, validation.IsValidAccessToken(got), "NewPublicDashboardAccessToken(%v)", tt.args.ctx) store.AssertNumberOfCalls(t, "FindByAccessToken", 1) } else { store.AssertNumberOfCalls(t, "FindByAccessToken", 3) @@ -1028,3 +1029,16 @@ func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardSto dash.Data.Set("uid", dash.UID) return dash } + +func TestGenerateAccessToken(t *testing.T) { + accessToken, err := GenerateAccessToken() + + t.Run("length", func(t *testing.T) { + require.NoError(t, err) + assert.Equal(t, 32, len(accessToken)) + }) + + t.Run("no - ", func(t *testing.T) { + assert.False(t, strings.Contains("-", accessToken)) + }) +} diff --git a/pkg/services/publicdashboards/validation/validation.go b/pkg/services/publicdashboards/validation/validation.go index 6f2c57c83dd..2094cf3545d 100644 --- a/pkg/services/publicdashboards/validation/validation.go +++ b/pkg/services/publicdashboards/validation/validation.go @@ -1,9 +1,11 @@ package validation import ( + "github.com/google/uuid" "github.com/grafana/grafana/pkg/services/dashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/tsdb/legacydata" + "github.com/grafana/grafana/pkg/util" ) func ValidatePublicDashboard(dto *SavePublicDashboardDTO, dashboard *dashboards.Dashboard) error { @@ -44,3 +46,15 @@ func ValidateQueryPublicDashboardRequest(req PublicDashboardQueryDTO, pd *Public return nil } + +// IsValidAccessToken asserts that an accessToken is a valid uuid +func IsValidAccessToken(token string) bool { + _, err := uuid.Parse(token) + return err == nil +} + +// IsValidShortUID checks that the uid is not blank and contains valid +// characters. Wraps utils.IsValidShortUID +func IsValidShortUID(uid string) bool { + return uid != "" && util.IsValidShortUID(uid) +} diff --git a/pkg/services/publicdashboards/validation/validation_test.go b/pkg/services/publicdashboards/validation/validation_test.go index 4ee281e20fb..5ba9dcd8c30 100644 --- a/pkg/services/publicdashboards/validation/validation_test.go +++ b/pkg/services/publicdashboards/validation/validation_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -157,3 +158,35 @@ func TestValidateQueryPublicDashboardRequest(t *testing.T) { }) } } + +func TestValidAccessToken(t *testing.T) { + t.Run("true", func(t *testing.T) { + uuid := "da82510c2aa64d78a2e87fef36c58e89" + assert.True(t, IsValidAccessToken(uuid)) + }) + + t.Run("false when blank", func(t *testing.T) { + assert.False(t, IsValidAccessToken("")) + }) + + t.Run("false when can't be parsed by uuid lib", func(t *testing.T) { + // too long + assert.False(t, IsValidAccessToken("0123456789012345678901234567890123456789")) + }) +} + +// we just check base cases since this wraps utils.IsValidShortUID which has +// test coverage +func TestValidUid(t *testing.T) { + t.Run("true", func(t *testing.T) { + assert.True(t, IsValidShortUID("afqrz7jZZ")) + }) + + t.Run("false when blank", func(t *testing.T) { + assert.False(t, IsValidShortUID("")) + }) + + t.Run("false when invalid chars", func(t *testing.T) { + assert.False(t, IsValidShortUID("afqrz7j%%")) + }) +} diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index e9c9c4a5c57..188fea5516a 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -16,11 +16,11 @@ import ( "github.com/grafana/grafana/pkg/expr" "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/models/roletype" "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" dsSvc "github.com/grafana/grafana/pkg/services/datasources/service" @@ -211,7 +211,7 @@ func TestParseMetricRequest(t *testing.T) { httpreq, err := http.NewRequest(http.MethodPost, "http://localhost/", bytes.NewReader([]byte{})) require.NoError(t, err) - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: &web.Context{}, } ctx := ctxkey.Set(context.Background(), reqCtx) @@ -325,7 +325,7 @@ func TestQueryDataMultipleSources(t *testing.T) { httpreq, err := http.NewRequest(http.MethodPost, "http://localhost/ds/query?expression=true", bytes.NewReader([]byte{})) require.NoError(t, err) - reqCtx := &models.ReqContext{ + reqCtx := &contextmodel.ReqContext{ Context: &web.Context{}, } ctx := ctxkey.Set(context.Background(), reqCtx) diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index 8b99fadb467..43a9fed05eb 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -36,7 +36,7 @@ func (s *QueryHistoryService) registerAPIEndpoints() { // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) createHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) createHandler(c *contextmodel.ReqContext) response.Response { cmd := CreateQueryInQueryHistoryCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -62,7 +62,7 @@ func (s *QueryHistoryService) createHandler(c *models.ReqContext) response.Respo // 200: getQueryHistorySearchResponse // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) searchHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) searchHandler(c *contextmodel.ReqContext) response.Response { timeRange := legacydata.NewDataTimeRange(c.Query("from"), c.Query("to")) query := SearchInQueryHistoryQuery{ @@ -94,7 +94,7 @@ func (s *QueryHistoryService) searchHandler(c *models.ReqContext) response.Respo // 200: getQueryHistoryDeleteQueryResponse // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) deleteHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) deleteHandler(c *contextmodel.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) @@ -122,7 +122,7 @@ func (s *QueryHistoryService) deleteHandler(c *models.ReqContext) response.Respo // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) patchCommentHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) patchCommentHandler(c *contextmodel.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) @@ -151,7 +151,7 @@ func (s *QueryHistoryService) patchCommentHandler(c *models.ReqContext) response // 200: getQueryHistoryResponse // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) starHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) starHandler(c *contextmodel.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) @@ -175,7 +175,7 @@ func (s *QueryHistoryService) starHandler(c *models.ReqContext) response.Respons // 200: getQueryHistoryResponse // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) unstarHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) unstarHandler(c *contextmodel.ReqContext) response.Response { queryUID := web.Params(c.Req)[":uid"] if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) @@ -200,7 +200,7 @@ func (s *QueryHistoryService) unstarHandler(c *models.ReqContext) response.Respo // 400: badRequestError // 401: unauthorisedError // 500: internalServerError -func (s *QueryHistoryService) migrateHandler(c *models.ReqContext) response.Response { +func (s *QueryHistoryService) migrateHandler(c *contextmodel.ReqContext) response.Response { cmd := MigrateQueriesToQueryHistoryCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 2976281b6d8..e598c45b67f 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -35,7 +35,7 @@ var ( type scenarioContext struct { ctx *web.Context service *QueryHistoryService - reqContext *models.ReqContext + reqContext *contextmodel.ReqContext sqlStore db.DB initialResult QueryHistoryResponse } @@ -82,7 +82,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo ctx: &ctx, service: &service, sqlStore: sqlStore, - reqContext: &models.ReqContext{ + reqContext: &contextmodel.ReqContext{ Context: &ctx, SignedInUser: &usr, }, diff --git a/pkg/services/querylibrary/querylibraryimpl/http.go b/pkg/services/querylibrary/querylibraryimpl/http.go index 34c25f3054c..10ee1d1fc91 100644 --- a/pkg/services/querylibrary/querylibraryimpl/http.go +++ b/pkg/services/querylibrary/querylibraryimpl/http.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/querylibrary" ) @@ -21,7 +21,7 @@ func (s *queriesServiceHTTPHandler) IsDisabled() bool { return s.service.IsDisabled() } -func (s *queriesServiceHTTPHandler) delete(c *models.ReqContext) response.Response { +func (s *queriesServiceHTTPHandler) delete(c *contextmodel.ReqContext) response.Response { uid := c.Query("uid") err := s.service.Delete(c.Req.Context(), c.SignedInUser, uid) if err != nil { @@ -40,7 +40,7 @@ func (s *queriesServiceHTTPHandler) RegisterHTTPRoutes(routes routing.RouteRegis routes.Delete("/", reqSignedIn, routing.Wrap(s.delete)) } -func (s *queriesServiceHTTPHandler) getBatch(c *models.ReqContext) response.Response { +func (s *queriesServiceHTTPHandler) getBatch(c *contextmodel.ReqContext) response.Response { uids := c.QueryStrings("uid") queries, err := s.service.GetBatch(c.Req.Context(), c.SignedInUser, uids) @@ -51,7 +51,7 @@ func (s *queriesServiceHTTPHandler) getBatch(c *models.ReqContext) response.Resp return response.JSON(200, queries) } -func (s *queriesServiceHTTPHandler) update(c *models.ReqContext) response.Response { +func (s *queriesServiceHTTPHandler) update(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(500, "error reading bytes", err) diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 13045f41de2..700db61a403 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -3,7 +3,7 @@ package quota import ( "context" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) type Service interface { @@ -16,7 +16,7 @@ type Service interface { // If the cmd.UseID is set, then the user quota are updated. Update(ctx context.Context, cmd *UpdateQuotaCmd) error // QuotaReached is called by the quota middleware for applying quota enforcement to API handlers - QuotaReached(c *models.ReqContext, targetSrv TargetSrv) (bool, error) + QuotaReached(c *contextmodel.ReqContext, targetSrv TargetSrv) (bool, error) // CheckQuotaReached checks if the quota limitations have been reached for a specific service CheckQuotaReached(ctx context.Context, targetSrv TargetSrv, scopeParams *ScopeParameters) (bool, error) // DeleteQuotaForUser deletes custom quota limitations for the user diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index 65763f20463..1eebaad635a 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "golang.org/x/sync/errgroup" @@ -15,7 +15,7 @@ import ( type serviceDisabled struct { } -func (s *serviceDisabled) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { +func (s *serviceDisabled) QuotaReached(c *contextmodel.ReqContext, targetSrv quota.TargetSrv) (bool, error) { return false, nil } @@ -75,7 +75,7 @@ func (s *service) IsDisabled() bool { } // QuotaReached checks that quota is reached for a target. Runs CheckQuotaReached and take context and scope parameters from the request context -func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { +func (s *service) QuotaReached(c *contextmodel.ReqContext, targetSrv quota.TargetSrv) (bool, error) { // No request context means this is a background service, like LDAP Background Sync if c == nil { return false, nil diff --git a/pkg/services/quota/quotatest/fake.go b/pkg/services/quota/quotatest/fake.go index d62267d9276..16fa2bc6403 100644 --- a/pkg/services/quota/quotatest/fake.go +++ b/pkg/services/quota/quotatest/fake.go @@ -3,7 +3,7 @@ package quotatest import ( "context" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/quota" ) @@ -24,7 +24,7 @@ func (f *FakeQuotaService) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd return nil } -func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func (f *FakeQuotaService) QuotaReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) { return f.reached, f.err } diff --git a/pkg/services/screenshot/screenshot.go b/pkg/services/screenshot/screenshot.go index eb5bf01ddfa..0e88b9c62f3 100644 --- a/pkg/services/screenshot/screenshot.go +++ b/pkg/services/screenshot/screenshot.go @@ -86,7 +86,8 @@ func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOpt defer func() { s.duration.Observe(time.Since(start).Seconds()) }() q := dashboards.GetDashboardQuery{UID: opts.DashboardUID} - if err := s.ds.GetDashboard(ctx, &q); err != nil { + qResult, err := s.ds.GetDashboard(ctx, &q) + if err != nil { s.instrumentError(err) return nil, err } @@ -94,9 +95,9 @@ func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOpt opts = opts.SetDefaults() u := url.URL{} - u.Path = path.Join("d-solo", q.Result.UID, q.Result.Slug) + u.Path = path.Join("d-solo", qResult.UID, qResult.Slug) p := u.Query() - p.Add("orgId", strconv.FormatInt(q.Result.OrgID, 10)) + p.Add("orgId", strconv.FormatInt(qResult.OrgID, 10)) p.Add("panelId", strconv.FormatInt(opts.PanelID, 10)) p.Add("from", opts.From) p.Add("to", opts.To) @@ -104,7 +105,7 @@ func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOpt renderOpts := rendering.Opts{ AuthOpts: rendering.AuthOpts{ - OrgID: q.Result.OrgID, + OrgID: qResult.OrgID, OrgRole: org.RoleAdmin, }, ErrorOpts: rendering.ErrorOpts{ diff --git a/pkg/services/screenshot/screenshot_test.go b/pkg/services/screenshot/screenshot_test.go index 42557f6956e..24794ea8527 100644 --- a/pkg/services/screenshot/screenshot_test.go +++ b/pkg/services/screenshot/screenshot_test.go @@ -26,7 +26,7 @@ func TestHeadlessScreenshotService(t *testing.T) { s := NewHeadlessScreenshotService(&d, r, prometheus.NewRegistry()) // a non-existent dashboard should return error - d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(dashboards.ErrDashboardNotFound).Once() + d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(nil, dashboards.ErrDashboardNotFound).Once() ctx := context.Background() opts := ScreenshotOptions{} screenshot, err := s.Take(ctx, opts) @@ -34,10 +34,8 @@ func TestHeadlessScreenshotService(t *testing.T) { assert.Nil(t, screenshot) // should take a screenshot - d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*dashboards.GetDashboardQuery) - q.Result = &dashboards.Dashboard{ID: 1, UID: "foo", Slug: "bar", OrgID: 2} - }).Return(nil) + qResult := &dashboards.Dashboard{ID: 1, UID: "foo", Slug: "bar", OrgID: 2} + d.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) renderOpts := rendering.Opts{ AuthOpts: rendering.AuthOpts{ diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index 0aff848cff5..8f45effe504 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -39,7 +39,7 @@ type Query struct { DashboardUIDs []string DashboardIds []int64 FolderIds []int64 - Permission models.PermissionType + Permission dashboards.PermissionType Sort string Result models.HitList @@ -59,7 +59,7 @@ type SearchService struct { } func (s *SearchService) SearchHandler(ctx context.Context, query *Query) error { - dashboardQuery := models.FindPersistedDashboardsQuery{ + dashboardQuery := dashboards.FindPersistedDashboardsQuery{ Title: query.Title, SignedInUser: query.SignedInUser, IsStarred: query.IsStarred, diff --git a/pkg/services/search/service_test.go b/pkg/services/search/service_test.go index 7694bbc356e..19b94758b68 100644 --- a/pkg/services/search/service_test.go +++ b/pkg/services/search/service_test.go @@ -22,8 +22,8 @@ func TestSearch_SortedResults(t *testing.T) { db := dbtest.NewFakeDB() us := usertest.NewUserServiceFake() ds := dashboards.NewFakeDashboardService(t) - ds.On("SearchDashboards", mock.Anything, mock.AnythingOfType("*models.FindPersistedDashboardsQuery")).Run(func(args mock.Arguments) { - q := args.Get(1).(*models.FindPersistedDashboardsQuery) + ds.On("SearchDashboards", mock.Anything, mock.AnythingOfType("*dashboards.FindPersistedDashboardsQuery")).Run(func(args mock.Arguments) { + q := args.Get(1).(*dashboards.FindPersistedDashboardsQuery) q.Result = models.HitList{ &models.Hit{ID: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}}, &models.Hit{ID: 10, Title: "AABB", Type: "dash-db", Tags: []string{"CC", "AA"}}, diff --git a/pkg/services/searchV2/auth.go b/pkg/services/searchV2/auth.go index f22e35f0869..60437f543cc 100644 --- a/pkg/services/searchV2/auth.go +++ b/pkg/services/searchV2/auth.go @@ -4,24 +4,23 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" - "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" ) // ResourceFilter checks if a given a uid (resource identifier) check if we have the requested permission -type ResourceFilter func(uid string) bool +type ResourceFilter func(kind entityKind, uid, parentUID string) bool // FutureAuthService eventually implemented by the security service type FutureAuthService interface { GetDashboardReadFilter(user *user.SignedInUser) (ResourceFilter, error) } -var _ FutureAuthService = (*simpleSQLAuthService)(nil) +var _ FutureAuthService = (*simpleAuthService)(nil) -type simpleSQLAuthService struct { +type simpleAuthService struct { sql db.DB ac accesscontrol.Service } @@ -30,22 +29,26 @@ type dashIdQueryResult struct { UID string `xorm:"uid"` } -func (a *simpleSQLAuthService) getDashboardTableAuthFilter(user *user.SignedInUser) searchstore.FilterWhere { - if a.ac.IsDisabled() { - return permissions.DashboardPermissionFilter{ - OrgRole: user.OrgRole, - OrgId: user.OrgID, - Dialect: a.sql.GetDialect(), - UserId: user.UserID, - PermissionLevel: models.PERMISSION_VIEW, - } +func (a *simpleAuthService) GetDashboardReadFilter(user *user.SignedInUser) (ResourceFilter, error) { + if !a.ac.IsDisabled() { + canReadDashboard, canReadFolder := accesscontrol.Checker(user, dashboards.ActionDashboardsRead), accesscontrol.Checker(user, dashboards.ActionFoldersRead) + return func(kind entityKind, uid, parent string) bool { + if kind == entityKindFolder { + return canReadFolder(dashboards.ScopeFoldersProvider.GetResourceScopeUID(uid)) + } else if kind == entityKindDashboard { + return canReadDashboard(dashboards.ScopeDashboardsProvider.GetResourceScopeUID(uid), dashboards.ScopeFoldersProvider.GetResourceScopeUID(parent)) + } + return false + }, nil } - return permissions.NewAccessControlDashboardPermissionFilter(user, models.PERMISSION_VIEW, "") -} - -func (a *simpleSQLAuthService) GetDashboardReadFilter(user *user.SignedInUser) (ResourceFilter, error) { - filter := a.getDashboardTableAuthFilter(user) + filter := permissions.DashboardPermissionFilter{ + OrgRole: user.OrgRole, + OrgId: user.OrgID, + Dialect: a.sql.GetDialect(), + UserId: user.UserID, + PermissionLevel: dashboards.PERMISSION_VIEW, + } rows := make([]*dashIdQueryResult, 0) err := a.sql.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -72,7 +75,7 @@ func (a *simpleSQLAuthService) GetDashboardReadFilter(user *user.SignedInUser) ( uids[rows[i].UID] = true } - return func(uid string) bool { + return func(_ entityKind, uid, _ string) bool { return uids[uid] }, err } diff --git a/pkg/services/searchV2/bluge.go b/pkg/services/searchV2/bluge.go index 93ecbb54ad2..223e46ec966 100644 --- a/pkg/services/searchV2/bluge.go +++ b/pkg/services/searchV2/bluge.go @@ -16,7 +16,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/slugify" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/store/entity" ) const ( @@ -70,6 +71,7 @@ func initOrgIndex(dashboards []dashboard, logger log.Logger, extendDoc ExtendDas // First index the folders to construct folderIdLookup. folderIdLookup := make(map[int64]string, 50) + folderIdLookup[0] = folder.GeneralFolderUID for _, dash := range dashboards { if !dash.isFolder { continue @@ -83,9 +85,6 @@ func initOrgIndex(dashboards []dashboard, logger log.Logger, extendDoc ExtendDas return nil, err } uid := dash.uid - if uid == "" { - uid = "general" - } folderIdLookup[dash.id] = uid } @@ -170,15 +169,15 @@ func getNonFolderDashboardDoc(dash dashboard, location string) *bluge.Document { } for _, ref := range dash.summary.References { - if ref.Kind == models.StandardKindDataSource { + if ref.Family == entity.StandardKindDataSource { if ref.Type != "" { doc.AddField(bluge.NewKeywordField(documentFieldDSType, ref.Type). StoreValue(). Aggregatable(). SearchTermPositions()) } - if ref.UID != "" { - doc.AddField(bluge.NewKeywordField(documentFieldDSUID, ref.UID). + if ref.Identifier != "" { + doc.AddField(bluge.NewKeywordField(documentFieldDSUID, ref.Identifier). StoreValue(). Aggregatable(). SearchTermPositions()) @@ -194,8 +193,8 @@ func getDashboardPanelDocs(dash dashboard, location string) []*bluge.Document { var docs []*bluge.Document for _, panel := range dash.summary.Nested { - if panel.Kind == "panel-row" { - continue // for now, we are excluding rows from the search index + if panel.Fields["type"] == "row" { + continue // skip rows } idx := strings.LastIndex(panel.UID, "#") panelId, err := strconv.Atoi(panel.UID[idx+1:]) @@ -209,27 +208,27 @@ func getDashboardPanelDocs(dash dashboard, location string) []*bluge.Document { AddField(bluge.NewKeywordField(documentFieldKind, string(entityKindPanel)).Aggregatable().StoreValue()) // likely want independent index for this for _, ref := range dash.summary.References { - switch ref.Kind { - case models.StandardKindDashboard: + switch ref.Family { + case entity.StandardKindDashboard: if ref.Type != "" { doc.AddField(bluge.NewKeywordField(documentFieldDSType, ref.Type). StoreValue(). Aggregatable(). SearchTermPositions()) } - if ref.UID != "" { - doc.AddField(bluge.NewKeywordField(documentFieldDSUID, ref.UID). + if ref.Identifier != "" { + doc.AddField(bluge.NewKeywordField(documentFieldDSUID, ref.Identifier). StoreValue(). Aggregatable(). SearchTermPositions()) } - case models.ExternalEntityReferencePlugin: - if ref.Type == models.StandardKindPanel && ref.UID != "" { - doc.AddField(bluge.NewKeywordField(documentFieldPanelType, ref.UID).Aggregatable().StoreValue()) + case entity.ExternalEntityReferencePlugin: + if ref.Type == entity.StandardKindPanel && ref.Identifier != "" { + doc.AddField(bluge.NewKeywordField(documentFieldPanelType, ref.Identifier).Aggregatable().StoreValue()) } - case models.ExternalEntityReferenceRuntime: - if ref.Type == models.ExternalEntityReferenceRuntime_Transformer && ref.UID != "" { - doc.AddField(bluge.NewKeywordField(documentFieldTransformer, ref.UID).Aggregatable()) + case entity.ExternalEntityReferenceRuntime: + if ref.Type == entity.ExternalEntityReferenceRuntime_Transformer && ref.Identifier != "" { + doc.AddField(bluge.NewKeywordField(documentFieldTransformer, ref.Identifier).Aggregatable()) } } } diff --git a/pkg/services/searchV2/filter.go b/pkg/services/searchV2/filter.go index 1a22f60c595..b68c79297ba 100644 --- a/pkg/services/searchV2/filter.go +++ b/pkg/services/searchV2/filter.go @@ -2,13 +2,15 @@ package searchV2 import ( "regexp" + "strings" "github.com/blugelabs/bluge" "github.com/blugelabs/bluge/search" "github.com/blugelabs/bluge/search/searcher" "github.com/blugelabs/bluge/search/similarity" + "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" ) type PermissionFilter struct { @@ -19,11 +21,11 @@ type PermissionFilter struct { type entityKind string const ( - entityKindPanel entityKind = models.StandardKindPanel - entityKindDashboard entityKind = models.StandardKindDashboard - entityKindFolder entityKind = models.StandardKindFolder - entityKindDatasource entityKind = models.StandardKindDataSource - entityKindQuery entityKind = models.StandardKindQuery + entityKindPanel entityKind = entity.StandardKindPanel + entityKindDashboard entityKind = entity.StandardKindDashboard + entityKindFolder entityKind = entity.StandardKindFolder + entityKindDatasource entityKind = entity.StandardKindDataSource + entityKindQuery entityKind = entity.StandardKindQuery ) func (r entityKind) IsValid() bool { @@ -35,7 +37,7 @@ func (r entityKind) supportsAuthzCheck() bool { } var ( - permissionFilterFields = []string{documentFieldUID, documentFieldKind} + permissionFilterFields = []string{documentFieldUID, documentFieldKind, documentFieldLocation} panelIdFieldRegex = regexp.MustCompile(`^(.*)#([0-9]{1,4})$`) panelIdFieldDashboardUidSubmatchIndex = 1 panelIdFieldPanelIdSubmatchIndex = 2 @@ -64,7 +66,7 @@ func (q *PermissionFilter) logAccessDecision(decision bool, kind interface{}, id } } -func (q *PermissionFilter) canAccess(kind entityKind, id string) bool { +func (q *PermissionFilter) canAccess(kind entityKind, id, location string) bool { if !kind.supportsAuthzCheck() { q.logAccessDecision(false, kind, id, "entityDoesNotSupportAuthz") return false @@ -73,29 +75,28 @@ func (q *PermissionFilter) canAccess(kind entityKind, id string) bool { // TODO add `kind` to the `ResourceFilter` interface so that we can move the switch out of here // switch kind { - case entityKindFolder: - if id == "" { - q.logAccessDecision(true, kind, id, "generalFolder") - return true - } - fallthrough - case entityKindDashboard: - decision := q.filter(id) + case entityKindFolder, entityKindDashboard: + decision := q.filter(kind, id, location) q.logAccessDecision(decision, kind, id, "resourceFilter") return decision case entityKindPanel: matches := panelIdFieldRegex.FindStringSubmatch(id) - submatchCount := len(matches) if submatchCount != panelIdFieldRegexExpectedSubmatchCount { q.logAccessDecision(false, kind, id, "invalidPanelIdFieldRegexSubmatchCount", "submatchCount", submatchCount, "expectedSubmatchCount", panelIdFieldRegexExpectedSubmatchCount) return false } - dashboardUid := matches[panelIdFieldDashboardUidSubmatchIndex] - decision := q.filter(dashboardUid) - q.logAccessDecision(decision, kind, id, "resourceFilter", "dashboardUid", dashboardUid, "panelId", matches[panelIdFieldPanelIdSubmatchIndex]) + // Location is / + if !strings.HasSuffix(location, "/"+dashboardUid) { + q.logAccessDecision(false, kind, id, "invalidLocation", "location", location, "dashboardUid", dashboardUid) + return false + } + folderUid := location[:len(location)-len(dashboardUid)-1] + + decision := q.filter(entityKindDashboard, dashboardUid, folderUid) + q.logAccessDecision(decision, kind, id, "resourceFilter", "folderUid", folderUid, "dashboardUid", dashboardUid, "panelId", matches[panelIdFieldPanelIdSubmatchIndex]) return decision default: q.logAccessDecision(false, kind, id, "reason", "unknownKind") @@ -110,13 +111,18 @@ func (q *PermissionFilter) Searcher(i search.Reader, options search.SearcherOpti } s, err := searcher.NewMatchAllSearcher(i, 1, similarity.ConstantScorer(1), options) + if err != nil { + return nil, err + } return searcher.NewFilteringSearcher(s, func(d *search.DocumentMatch) bool { - var kind, id string + var kind, id, location string err := dvReader.VisitDocumentValues(d.Number, func(field string, term []byte) { if field == documentFieldKind { kind = string(term) } else if field == documentFieldUID { id = string(term) + } else if field == documentFieldLocation { + location = string(term) } }) if err != nil { @@ -130,6 +136,6 @@ func (q *PermissionFilter) Searcher(i search.Reader, options search.SearcherOpti return false } - return q.canAccess(e, id) + return q.canAccess(e, id, location) }), err } diff --git a/pkg/services/searchV2/http.go b/pkg/services/searchV2/http.go index 0d1da01ad83..4bc03fcb1ee 100644 --- a/pkg/services/searchV2/http.go +++ b/pkg/services/searchV2/http.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/prometheus/client_golang/prometheus" ) @@ -30,7 +30,7 @@ func (s *searchHTTPService) RegisterHTTPRoutes(storageRoute routing.RouteRegiste storageRoute.Post("/", middleware.ReqSignedIn, routing.Wrap(s.doQuery)) } -func (s *searchHTTPService) doQuery(c *models.ReqContext) response.Response { +func (s *searchHTTPService) doQuery(c *contextmodel.ReqContext) response.Response { searchReadinessCheckResp := s.search.IsReady(c.Req.Context(), c.OrgID) if !searchReadinessCheckResp.IsReady { dashboardSearchNotServedRequestsCounter.With(prometheus.Labels{ diff --git a/pkg/services/searchV2/index.go b/pkg/services/searchV2/index.go index 0e92a96993e..0c15395cd55 100644 --- a/pkg/services/searchV2/index.go +++ b/pkg/services/searchV2/index.go @@ -19,9 +19,10 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" kdash "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/setting" ) @@ -54,7 +55,7 @@ type dashboard struct { updated time.Time // Use generic structure - summary *models.EntitySummary + summary *entity.EntitySummary } // buildSignal is sent when search index is accessed in organization for which @@ -761,7 +762,7 @@ func (i *searchIndex) updateDashboard(ctx context.Context, orgID int64, index *o var folderUID string if dash.folderID == 0 { - folderUID = "general" + folderUID = folder.GeneralFolderUID } else { var err error folderUID, err = i.folderIdLookup(ctx, dash.folderID) @@ -901,22 +902,7 @@ func (l sqlDashboardLoader) LoadDashboards(ctx context.Context, orgID int64, das if dashboardUID == "" { limit = l.settings.DashboardLoadingBatchSize - dashboards = make([]dashboard, 0, limit+1) - - // Add the root folder ID (does not exist in SQL). - dashboards = append(dashboards, dashboard{ - id: 0, - uid: "", - isFolder: true, - folderID: 0, - slug: "", - created: time.Now(), - updated: time.Now(), - summary: &models.EntitySummary{ - //ID: 0, - Name: "General", - }, - }) + dashboards = make([]dashboard, 0, limit) } loadDatasourceCtx, loadDatasourceSpan := l.tracer.Start(ctx, "sqlDashboardLoader LoadDatasourceLookup") diff --git a/pkg/services/searchV2/index_test.go b/pkg/services/searchV2/index_test.go index 1e56e77ff7c..69ab523cf8f 100644 --- a/pkg/services/searchV2/index_test.go +++ b/pkg/services/searchV2/index_test.go @@ -8,13 +8,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/setting" "github.com/blugelabs/bluge" "github.com/grafana/grafana-plugin-sdk-go/experimental" @@ -31,11 +31,11 @@ func (t *testDashboardLoader) LoadDashboards(_ context.Context, _ int64, _ strin var testLogger = log.New("index-test-logger") -var testAllowAllFilter = func(uid string) bool { +var testAllowAllFilter = func(kind entityKind, uid, parent string) bool { return true } -var testDisallowAllFilter = func(uid string) bool { +var testDisallowAllFilter = func(kind entityKind, uid, parent string) bool { return false } @@ -113,14 +113,14 @@ var testDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "test", }, }, { id: 2, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "boom", }, }, @@ -162,7 +162,7 @@ func TestDashboardIndexUpdates(t *testing.T) { err := index.updateDashboard(context.Background(), testOrgID, orgIdx, dashboard{ id: 3, uid: "3", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "created", }, }) @@ -181,7 +181,7 @@ func TestDashboardIndexUpdates(t *testing.T) { err := index.updateDashboard(context.Background(), testOrgID, orgIdx, dashboard{ id: 2, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "nginx", }, }) @@ -197,14 +197,14 @@ var testSortDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "a-test", }, }, { id: 2, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "z-test", }, }, @@ -288,14 +288,14 @@ var testPrefixDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Archer Data System", }, }, { id: 2, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Document Sync repo", }, }, @@ -366,7 +366,7 @@ var longPrefixDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Eyjafjallajökull Eruption data", }, }, @@ -385,14 +385,14 @@ var scatteredTokensDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Three can keep a secret, if two of them are dead (Benjamin Franklin)", }, }, { id: 3, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "A secret is powerful when it is empty (Umberto Eco)", }, }, @@ -418,7 +418,7 @@ var dashboardsWithFolders = []dashboard{ id: 1, uid: "1", isFolder: true, - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "My folder", }, }, @@ -426,11 +426,11 @@ var dashboardsWithFolders = []dashboard{ id: 2, uid: "2", folderID: 1, - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Dashboard in folder 1", - Nested: []*models.EntitySummary{ - newNestedPanel(1, "Panel 1"), - newNestedPanel(2, "Panel 2"), + Nested: []*entity.EntitySummary{ + newNestedPanel(1, 2, "Panel 1"), + newNestedPanel(2, 2, "Panel 2"), }, }, }, @@ -438,20 +438,20 @@ var dashboardsWithFolders = []dashboard{ id: 3, uid: "3", folderID: 1, - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "Dashboard in folder 2", - Nested: []*models.EntitySummary{ - newNestedPanel(3, "Panel 3"), + Nested: []*entity.EntitySummary{ + newNestedPanel(3, 3, "Panel 3"), }, }, }, { id: 4, uid: "4", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "One more dash", - Nested: []*models.EntitySummary{ - newNestedPanel(4, "Panel 4"), + Nested: []*entity.EntitySummary{ + newNestedPanel(4, 4, "Panel 4"), }, }, }, @@ -505,20 +505,20 @@ var dashboardsWithPanels = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "My Dash", - Nested: []*models.EntitySummary{ - newNestedPanel(1, "Panel 1"), - newNestedPanel(2, "Panel 2"), + Nested: []*entity.EntitySummary{ + newNestedPanel(1, 1, "Panel 1"), + newNestedPanel(2, 1, "Panel 2"), }, }, }, } -func newNestedPanel(id int64, name string) *models.EntitySummary { - summary := &models.EntitySummary{ +func newNestedPanel(id, dashId int64, name string) *entity.EntitySummary { + summary := &entity.EntitySummary{ Kind: "panel", - UID: fmt.Sprintf("???#%d", id), + UID: fmt.Sprintf("%d#%d", dashId, id), } summary.Name = name return summary @@ -553,14 +553,14 @@ var punctuationSplitNgramDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "heat-torkel", }, }, { id: 2, uid: "2", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "topology heatmap", }, }, @@ -586,7 +586,7 @@ var camelCaseNgramDashboards = []dashboard{ { id: 1, uid: "1", - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: "heatTorkel", }, }, @@ -608,7 +608,7 @@ func dashboardsWithTitles(names ...string) []dashboard { out = append(out, dashboard{ id: no, uid: fmt.Sprintf("%d", no), - summary: &models.EntitySummary{ + summary: &entity.EntitySummary{ Name: name, }, }) diff --git a/pkg/services/searchV2/service.go b/pkg/services/searchV2/service.go index e510709172f..3f611f51f99 100644 --- a/pkg/services/searchV2/service.go +++ b/pkg/services/searchV2/service.go @@ -91,7 +91,7 @@ func ProvideService(cfg *setting.Cfg, sql db.DB, entityEventStore store.EntityEv cfg: cfg, sql: sql, ac: ac, - auth: &simpleSQLAuthService{ + auth: &simpleAuthService{ sql: sql, ac: ac, }, diff --git a/pkg/services/searchV2/service_bench_test.go b/pkg/services/searchV2/service_bench_test.go new file mode 100644 index 00000000000..9fc9fbfa573 --- /dev/null +++ b/pkg/services/searchV2/service_bench_test.go @@ -0,0 +1,211 @@ +package searchV2 + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/querylibrary/querylibraryimpl" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + + "github.com/stretchr/testify/require" +) + +// setupBenchEnv will set up a database with folderCount folders and dashboardsPerFolder dashboards per folder +// It will also set up and run the search service +// and create a signed in user object with explicit permissions on each dashboard and folder. +func setupBenchEnv(b *testing.B, folderCount, dashboardsPerFolder int) (*StandardSearchService, *user.SignedInUser, error) { + sqlStore := db.InitTestDB(b) + err := populateDB(folderCount, dashboardsPerFolder, sqlStore) + require.NoError(b, err, "error when populating the database") + + // load all dashboards and folders + dbLoadingBatchSize := (dashboardsPerFolder + 1) * folderCount + cfg := &setting.Cfg{Search: setting.SearchSettings{DashboardLoadingBatchSize: dbLoadingBatchSize}} + features := featuremgmt.WithFeatures() + orgSvc := &orgtest.FakeOrgService{ + ExpectedOrgs: []*org.OrgDTO{{ID: 1}}, + } + querySvc := querylibraryimpl.ProvideService(cfg, features) + searchService, ok := ProvideService(cfg, sqlStore, store.NewDummyEntityEventsService(), actest.FakeService{}, + tracing.InitializeTracerForTest(), features, orgSvc, nil, querySvc).(*StandardSearchService) + require.True(b, ok) + + err = runSearchService(searchService) + require.NoError(b, err, "error when running search service") + + user := getSignedInUser(folderCount, dashboardsPerFolder) + + return searchService, user, nil +} + +// Returns a signed in user object with permissions on all dashboards and folders +func getSignedInUser(folderCount, dashboardsPerFolder int) *user.SignedInUser { + folderScopes := make([]string, folderCount) + for i := 1; i <= folderCount; i++ { + folderScopes[i-1] = dashboards.ScopeFoldersProvider.GetResourceScopeUID(fmt.Sprintf("folder%d", i)) + } + + dashScopes := make([]string, folderCount*dashboardsPerFolder) + for i := folderCount + 1; i <= (folderCount * (dashboardsPerFolder + 1)); i++ { + dashScopes[i-(folderCount+1)] = dashboards.ScopeDashboardsProvider.GetResourceScopeUID(fmt.Sprintf("dashboard%d", i)) + } + + user := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: { + dashboards.ActionDashboardsRead: dashScopes, + dashboards.ActionFoldersRead: folderScopes, + }, + }, + } + + return user +} + +// Runs initial indexing of search service +func runSearchService(searchService *StandardSearchService) error { + if err := searchService.dashboardIndex.buildInitialIndexes(context.Background(), []int64{int64(1)}); err != nil { + return err + } + searchService.dashboardIndex.initialIndexingComplete = true + + // Required for sync that is called during dashboard search + go func() { + for { + doneCh := <-searchService.dashboardIndex.syncCh + close(doneCh) + } + }() + + return nil +} + +// Populates database with dashboards and folders +func populateDB(folderCount, dashboardsPerFolder int, sqlStore *sqlstore.SQLStore) error { + // Insert folders + offset := 1 + if errInsert := actest.ConcurrentBatch(actest.Concurrency, folderCount, actest.BatchSize, func(start, end int) error { + n := end - start + folders := make([]dashboards.Dashboard, 0, n) + now := time.Now() + + for u := start; u < end; u++ { + folderID := int64(u + offset) + folders = append(folders, dashboards.Dashboard{ + ID: folderID, + UID: fmt.Sprintf("folder%v", folderID), + Title: fmt.Sprintf("folder%v", folderID), + IsFolder: true, + OrgID: 1, + Created: now, + Updated: now, + }) + } + + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + if _, err := sess.Insert(folders); err != nil { + return err + } + return nil + }) + return err + }); errInsert != nil { + return errInsert + } + + // Insert dashboards + offset += folderCount + if errInsert := actest.ConcurrentBatch(actest.Concurrency, dashboardsPerFolder*folderCount, actest.BatchSize, func(start, end int) error { + n := end - start + dbs := make([]dashboards.Dashboard, 0, n) + now := time.Now() + + for u := start; u < end; u++ { + dashID := int64(u + offset) + folderID := int64((u+offset)%folderCount + 1) + dbs = append(dbs, dashboards.Dashboard{ + ID: dashID, + UID: fmt.Sprintf("dashboard%v", dashID), + Title: fmt.Sprintf("dashboard%v", dashID), + IsFolder: false, + FolderID: folderID, + OrgID: 1, + Created: now, + Updated: now, + }) + } + + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + if _, err := sess.Insert(dbs); err != nil { + return err + } + return nil + }) + return err + }); errInsert != nil { + return errInsert + } + + return nil +} + +func benchSearchV2(b *testing.B, folderCount, dashboardsPerFolder int) { + searchService, testUser, err := setupBenchEnv(b, folderCount, dashboardsPerFolder) + require.NoError(b, err) + + b.ResetTimer() + + expectedResultCount := (dashboardsPerFolder + 1) * folderCount + for n := 0; n < b.N; n++ { + result := searchService.doDashboardQuery(context.Background(), testUser, 1, DashboardQuery{Limit: expectedResultCount}) + require.NoError(b, result.Error) + require.NotZero(b, len(result.Frames)) + for _, field := range result.Frames[0].Fields { + if field.Name == "uid" { + require.Equal(b, expectedResultCount, field.Len()) + break + } + } + } +} + +// Test with some dashboards and some folders +func BenchmarkSearchV2_10_10(b *testing.B) { + benchSearchV2(b, 10, 10) +} // ~0.0002 s/op +func BenchmarkSearchV2_10_100(b *testing.B) { + benchSearchV2(b, 10, 100) +} // ~0.002 s/op + +// Test with many dashboards and only one folder +func BenchmarkSearchV2_1_1k(b *testing.B) { + benchSearchV2(b, 1, 1000) +} // ~0.002 s/op +func BenchmarkSearchV2_1_10k(b *testing.B) { + benchSearchV2(b, 1, 10000) +} // ~0.019 s/op + +// Test with a large number of dashboards and folders +func BenchmarkSearchV2_100_100(b *testing.B) { + benchSearchV2(b, 100, 100) +} // ~0.02 s/op +func BenchmarkSearchV2_100_1k(b *testing.B) { + benchSearchV2(b, 100, 1000) +} // ~0.22 s/op +func BenchmarkSearchV2_1k_100(b *testing.B) { + benchSearchV2(b, 1000, 100) +} // ~0.22 s/op diff --git a/pkg/services/searchV2/testdata/basic-search.jsonc b/pkg/services/searchV2/testdata/basic-search.jsonc index 0d1784474ed..75d20de5933 100644 --- a/pkg/services/searchV2/testdata/basic-search.jsonc +++ b/pkg/services/searchV2/testdata/basic-search.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 2 | boom | | /pfix/d/2/ | null | [] | | +// | dashboard | 2 | boom | | /pfix/d/2/ | null | [] | general | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc b/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc index 019e0ba2b28..29082357672 100644 --- a/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc +++ b/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 4 | One more dash | | /pfix/d/4/ | null | [] | | +// | dashboard | 4 | One more dash | | /pfix/d/4/ | null | [] | general | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/multiple-tokens-beginning-lower.jsonc b/pkg/services/searchV2/testdata/multiple-tokens-beginning-lower.jsonc index 4536d5a36a0..3f1af3821bd 100644 --- a/pkg/services/searchV2/testdata/multiple-tokens-beginning-lower.jsonc +++ b/pkg/services/searchV2/testdata/multiple-tokens-beginning-lower.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/multiple-tokens-beginning.jsonc b/pkg/services/searchV2/testdata/multiple-tokens-beginning.jsonc index 4536d5a36a0..3f1af3821bd 100644 --- a/pkg/services/searchV2/testdata/multiple-tokens-beginning.jsonc +++ b/pkg/services/searchV2/testdata/multiple-tokens-beginning.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/multiple-tokens-middle-lower.jsonc b/pkg/services/searchV2/testdata/multiple-tokens-middle-lower.jsonc index 4102a2ad602..87e8e460095 100644 --- a/pkg/services/searchV2/testdata/multiple-tokens-middle-lower.jsonc +++ b/pkg/services/searchV2/testdata/multiple-tokens-middle-lower.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | | +// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/multiple-tokens-middle.jsonc b/pkg/services/searchV2/testdata/multiple-tokens-middle.jsonc index 4536d5a36a0..3f1af3821bd 100644 --- a/pkg/services/searchV2/testdata/multiple-tokens-middle.jsonc +++ b/pkg/services/searchV2/testdata/multiple-tokens-middle.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/ngram-camel-case-split.jsonc b/pkg/services/searchV2/testdata/ngram-camel-case-split.jsonc index 8a43418872a..c49bf638f3c 100644 --- a/pkg/services/searchV2/testdata/ngram-camel-case-split.jsonc +++ b/pkg/services/searchV2/testdata/ngram-camel-case-split.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | heatTorkel | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | heatTorkel | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/ngram-punctuation-split.jsonc b/pkg/services/searchV2/testdata/ngram-punctuation-split.jsonc index a6501a132b3..bf363db06c3 100644 --- a/pkg/services/searchV2/testdata/ngram-punctuation-split.jsonc +++ b/pkg/services/searchV2/testdata/ngram-punctuation-split.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | heat-torkel | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | heat-torkel | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/ngram-simple.jsonc b/pkg/services/searchV2/testdata/ngram-simple.jsonc index 0a85bf090b1..a9b65c0e829 100644 --- a/pkg/services/searchV2/testdata/ngram-simple.jsonc +++ b/pkg/services/searchV2/testdata/ngram-simple.jsonc @@ -13,8 +13,8 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | heat-torkel | | /pfix/d/1/ | null | [] | | -// | dashboard | 2 | topology heatmap | | /pfix/d/2/ | null | [] | | +// | dashboard | 1 | heat-torkel | | /pfix/d/1/ | null | [] | general | +// | dashboard | 2 | topology heatmap | | /pfix/d/2/ | null | [] | general | // +----------------+----------------+------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -130,8 +130,8 @@ [] ], [ - "", - "" + "general", + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/prefix-search-beginning-lower.jsonc b/pkg/services/searchV2/testdata/prefix-search-beginning-lower.jsonc index 4536d5a36a0..3f1af3821bd 100644 --- a/pkg/services/searchV2/testdata/prefix-search-beginning-lower.jsonc +++ b/pkg/services/searchV2/testdata/prefix-search-beginning-lower.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/prefix-search-beginning.jsonc b/pkg/services/searchV2/testdata/prefix-search-beginning.jsonc index 4536d5a36a0..3f1af3821bd 100644 --- a/pkg/services/searchV2/testdata/prefix-search-beginning.jsonc +++ b/pkg/services/searchV2/testdata/prefix-search-beginning.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Archer Data System | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/prefix-search-middle-lower.jsonc b/pkg/services/searchV2/testdata/prefix-search-middle-lower.jsonc index 4102a2ad602..87e8e460095 100644 --- a/pkg/services/searchV2/testdata/prefix-search-middle-lower.jsonc +++ b/pkg/services/searchV2/testdata/prefix-search-middle-lower.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | | +// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/prefix-search-middle.jsonc b/pkg/services/searchV2/testdata/prefix-search-middle.jsonc index 4102a2ad602..87e8e460095 100644 --- a/pkg/services/searchV2/testdata/prefix-search-middle.jsonc +++ b/pkg/services/searchV2/testdata/prefix-search-middle.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | | +// | dashboard | 2 | Document Sync repo | | /pfix/d/2/ | null | [] | general | // +----------------+----------------+--------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/prefix-search-ngram-exceeded.jsonc b/pkg/services/searchV2/testdata/prefix-search-ngram-exceeded.jsonc index 2e64a8cb02d..b09b73885d4 100644 --- a/pkg/services/searchV2/testdata/prefix-search-ngram-exceeded.jsonc +++ b/pkg/services/searchV2/testdata/prefix-search-ngram-exceeded.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+--------------------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Eyjafjallajökull Eruption data | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Eyjafjallajökull Eruption data | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+--------------------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/scattered-tokens-match.jsonc b/pkg/services/searchV2/testdata/scattered-tokens-match.jsonc index 19404b6a481..84be3a0a752 100644 --- a/pkg/services/searchV2/testdata/scattered-tokens-match.jsonc +++ b/pkg/services/searchV2/testdata/scattered-tokens-match.jsonc @@ -13,7 +13,7 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | // +----------------+----------------+----------------------------------------------------------------------+------------------+----------------+--------------------------+-------------------------+----------------+ -// | dashboard | 1 | Three can keep a secret, if two of them are dead (Benjamin Franklin) | | /pfix/d/1/ | null | [] | | +// | dashboard | 1 | Three can keep a secret, if two of them are dead (Benjamin Franklin) | | /pfix/d/1/ | null | [] | general | // +----------------+----------------+----------------------------------------------------------------------+------------------+----------------+--------------------------+-------------------------+----------------+ // // @@ -122,7 +122,7 @@ [] ], [ - "" + "general" ] ] } diff --git a/pkg/services/searchV2/testdata/sort-asc.jsonc b/pkg/services/searchV2/testdata/sort-asc.jsonc index a284a955e7f..c53caec66f3 100644 --- a/pkg/services/searchV2/testdata/sort-asc.jsonc +++ b/pkg/services/searchV2/testdata/sort-asc.jsonc @@ -14,8 +14,8 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | Type: []float64 | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+-----------------+ -// | dashboard | 1 | a-test | | /pfix/d/1/ | null | [] | | 0 | -// | dashboard | 2 | z-test | | /pfix/d/2/ | null | [] | | 1 | +// | dashboard | 1 | a-test | | /pfix/d/1/ | null | [] | general | 0 | +// | dashboard | 2 | z-test | | /pfix/d/2/ | null | [] | general | 1 | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+-----------------+ // // @@ -139,8 +139,8 @@ [] ], [ - "", - "" + "general", + "general" ], [ 0, diff --git a/pkg/services/searchV2/testdata/sort-desc.jsonc b/pkg/services/searchV2/testdata/sort-desc.jsonc index 998c5cf254d..540d0e82b62 100644 --- a/pkg/services/searchV2/testdata/sort-desc.jsonc +++ b/pkg/services/searchV2/testdata/sort-desc.jsonc @@ -14,8 +14,8 @@ // | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | // | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []json.RawMessage | Type: []string | Type: []float64 | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+-----------------+ -// | dashboard | 2 | z-test | | /pfix/d/2/ | null | [] | | 3 | -// | dashboard | 1 | a-test | | /pfix/d/1/ | null | [] | | 2 | +// | dashboard | 2 | z-test | | /pfix/d/2/ | null | [] | general | 3 | +// | dashboard | 1 | a-test | | /pfix/d/1/ | null | [] | general | 2 | // +----------------+----------------+----------------+------------------+----------------+--------------------------+-------------------------+----------------+-----------------+ // // @@ -139,8 +139,8 @@ [] ], [ - "", - "" + "general", + "general" ], [ 3, diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index 4e57c678036..bd82503d691 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -5,14 +5,14 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" ) type Service interface { - SearchUsers(c *models.ReqContext) response.Response - SearchUsersWithPaging(c *models.ReqContext) response.Response + SearchUsers(c *contextmodel.ReqContext) response.Response + SearchUsersWithPaging(c *contextmodel.ReqContext) response.Response } type OSSService struct { @@ -39,7 +39,7 @@ func ProvideUsersService(searchUserFilter user.SearchUserFilter, userService use // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (s *OSSService) SearchUsers(c *models.ReqContext) response.Response { +func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { return response.Error(500, "Failed to fetch users", err) @@ -58,7 +58,7 @@ func (s *OSSService) SearchUsers(c *models.ReqContext) response.Response { // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Response { +func (s *OSSService) SearchUsersWithPaging(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { return response.Error(500, "Failed to fetch users", err) @@ -67,7 +67,7 @@ func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Respon return response.JSON(http.StatusOK, result) } -func (s *OSSService) SearchUser(c *models.ReqContext) (*user.SearchUserQueryResult, error) { +func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQueryResult, error) { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 diff --git a/pkg/services/secrets/database/database.go b/pkg/services/secrets/database/database.go index 74cf5475fcd..5511d4832c7 100644 --- a/pkg/services/secrets/database/database.go +++ b/pkg/services/secrets/database/database.go @@ -5,8 +5,6 @@ import ( "fmt" "time" - "xorm.io/xorm" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/kmsproviders" @@ -16,22 +14,24 @@ import ( const dataKeysTable = "data_keys" type SecretsStoreImpl struct { - sqlStore db.DB - log log.Logger + db db.DB + log log.Logger } -func ProvideSecretsStore(sqlStore db.DB) *SecretsStoreImpl { - return &SecretsStoreImpl{ - sqlStore: sqlStore, - log: log.New("secrets.store"), +func ProvideSecretsStore(db db.DB) *SecretsStoreImpl { + store := &SecretsStoreImpl{ + db: db, + log: log.New("secrets.store"), } + + return store } func (ss *SecretsStoreImpl) GetDataKey(ctx context.Context, id string) (*secrets.DataKey, error) { dataKey := &secrets.DataKey{} var exists bool - err := ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var err error exists, err = sess.Table(dataKeysTable). Where("name = ?", id). @@ -54,10 +54,10 @@ func (ss *SecretsStoreImpl) GetCurrentDataKey(ctx context.Context, label string) dataKey := &secrets.DataKey{} var exists bool - err := ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var err error exists, err = sess.Table(dataKeysTable). - Where("label = ? AND active = ?", label, ss.sqlStore.GetDialect().BooleanStr(true)). + Where("label = ? AND active = ?", label, ss.db.GetDialect().BooleanStr(true)). Get(dataKey) return err }) @@ -75,7 +75,7 @@ func (ss *SecretsStoreImpl) GetCurrentDataKey(ctx context.Context, label string) func (ss *SecretsStoreImpl) GetAllDataKeys(ctx context.Context) ([]*secrets.DataKey, error) { result := make([]*secrets.DataKey, 0) - err := ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { err := sess.Table(dataKeysTable).Find(&result) return err }) @@ -83,12 +83,6 @@ func (ss *SecretsStoreImpl) GetAllDataKeys(ctx context.Context) ([]*secrets.Data } func (ss *SecretsStoreImpl) CreateDataKey(ctx context.Context, dataKey *secrets.DataKey) error { - return ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - return ss.CreateDataKeyWithDBSession(ctx, dataKey, sess.Session) - }) -} - -func (ss *SecretsStoreImpl) CreateDataKeyWithDBSession(_ context.Context, dataKey *secrets.DataKey, sess *xorm.Session) error { if !dataKey.Active { return fmt.Errorf("cannot insert deactivated data keys") } @@ -96,14 +90,20 @@ func (ss *SecretsStoreImpl) CreateDataKeyWithDBSession(_ context.Context, dataKe dataKey.Created = time.Now() dataKey.Updated = dataKey.Created - _, err := sess.Table(dataKeysTable).Insert(dataKey) - return err + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table(dataKeysTable).Insert(dataKey) + if err != nil { + return err + } + + return nil + }) } func (ss *SecretsStoreImpl) DisableDataKeys(ctx context.Context) error { - return ss.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Table(dataKeysTable). - Where("active = ?", ss.sqlStore.GetDialect().BooleanStr(true)). + Where("active = ?", ss.db.GetDialect().BooleanStr(true)). UseBool("active").Update(&secrets.DataKey{Active: false}) return err }) @@ -114,7 +114,7 @@ func (ss *SecretsStoreImpl) DeleteDataKey(ctx context.Context, id string) error return fmt.Errorf("data key id is missing") } - return ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithDbSession(ctx, func(sess *db.Session) error { _, err := sess.Table(dataKeysTable).Delete(&secrets.DataKey{Id: id}) return err @@ -127,14 +127,14 @@ func (ss *SecretsStoreImpl) ReEncryptDataKeys( currProvider secrets.ProviderID, ) error { keys := make([]*secrets.DataKey, 0) - if err := ss.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { return sess.Table(dataKeysTable).Find(&keys) }); err != nil { return err } for _, k := range keys { - err := ss.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { provider, ok := providers[kmsproviders.NormalizeProviderID(k.Provider)] if !ok { ss.log.Warn( diff --git a/pkg/services/secrets/fakes/fake_store.go b/pkg/services/secrets/fakes/fake_store.go index 12650a87615..5b4080c3d50 100644 --- a/pkg/services/secrets/fakes/fake_store.go +++ b/pkg/services/secrets/fakes/fake_store.go @@ -4,7 +4,6 @@ import ( "context" "github.com/grafana/grafana/pkg/services/secrets" - "xorm.io/xorm" ) type FakeSecretsStore struct { @@ -47,11 +46,6 @@ func (f FakeSecretsStore) CreateDataKey(_ context.Context, dataKey *secrets.Data return nil } -func (f FakeSecretsStore) CreateDataKeyWithDBSession(_ context.Context, dataKey *secrets.DataKey, _ *xorm.Session) error { - f.store[dataKey.Id] = dataKey - return nil -} - func (f FakeSecretsStore) DisableDataKeys(_ context.Context) error { for id := range f.store { f.store[id].Active = false diff --git a/pkg/services/secrets/manager/cache.go b/pkg/services/secrets/manager/cache.go index 7ea65d11ec6..1aca3a36113 100644 --- a/pkg/services/secrets/manager/cache.go +++ b/pkg/services/secrets/manager/cache.go @@ -8,10 +8,6 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -var ( - now = time.Now -) - type dataKeyCacheEntry struct { id string label string @@ -75,13 +71,21 @@ func (c *dataKeyCache) getByLabel(label string) (*dataKeyCacheEntry, bool) { return entry, true } -func (c *dataKeyCache) add(entry *dataKeyCacheEntry) { +func (c *dataKeyCache) addById(entry *dataKeyCacheEntry) { c.mtx.Lock() defer c.mtx.Unlock() entry.expiration = now().Add(c.cacheTTL) c.byId[entry.id] = entry +} + +func (c *dataKeyCache) addByLabel(entry *dataKeyCacheEntry) { + c.mtx.Lock() + defer c.mtx.Unlock() + + entry.expiration = now().Add(c.cacheTTL) + c.byLabel[entry.label] = entry } diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 5d7581a9da2..c9c5c2b9967 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -19,15 +19,21 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/prometheus/client_golang/prometheus" "golang.org/x/sync/errgroup" - "xorm.io/xorm" ) const ( keyIdDelimiter = '#' ) +var ( + // now is used for testing purposes, + // as a way to fake time.Now function. + now = time.Now +) + type SecretsService struct { store secrets.Store enc encryption.Internal @@ -152,10 +158,6 @@ func (s *SecretsService) encryptedWithEnvelopeEncryption(payload []byte) bool { var b64 = base64.RawStdEncoding func (s *SecretsService) Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) { - return s.EncryptWithDBSession(ctx, payload, opt, nil) -} - -func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byte, opt secrets.EncryptionOptions, sess *xorm.Session) ([]byte, error) { // Use legacy encryption service if featuremgmt.FlagDisableEnvelopeEncryption toggle is on if s.features.IsEnabled(featuremgmt.FlagDisableEnvelopeEncryption) { return s.enc.Encrypt(ctx, payload, setting.SecretKey) @@ -175,7 +177,7 @@ func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byt var id string var dataKey []byte - id, dataKey, err = s.currentDataKey(ctx, label, scope, sess) + id, dataKey, err = s.currentDataKey(ctx, label, scope) if err != nil { s.log.Error("Failed to get current data key", "error", err, "label", label) return nil, err @@ -203,7 +205,7 @@ func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byt // currentDataKey looks up for current data key in cache or database by name, and decrypts it. // If there's no current data key in cache nor in database it generates a new random data key, // and stores it into both the in-memory cache and database (encrypted by the encryption provider). -func (s *SecretsService) currentDataKey(ctx context.Context, label string, scope string, sess *xorm.Session) (string, []byte, error) { +func (s *SecretsService) currentDataKey(ctx context.Context, label string, scope string) (string, []byte, error) { // We want only one request fetching current data key at time to // avoid the creation of multiple ones in case there's no one existing. s.mtx.Lock() @@ -217,7 +219,7 @@ func (s *SecretsService) currentDataKey(ctx context.Context, label string, scope // If no existing data key was found, create a new one if dataKey == nil { - id, dataKey, err = s.newDataKey(ctx, label, scope, sess) + id, dataKey, err = s.newDataKey(ctx, label, scope) if err != nil { return "", nil, err } @@ -226,7 +228,7 @@ func (s *SecretsService) currentDataKey(ctx context.Context, label string, scope return id, dataKey, nil } -// dataKeyByLabel looks up for data key in cache. +// dataKeyByLabel looks up for data key in cache by label. // Otherwise, it fetches it from database, decrypts it and caches it decrypted. func (s *SecretsService) dataKeyByLabel(ctx context.Context, label string) (string, []byte, error) { // 0. Get data key from in-memory cache. @@ -256,18 +258,13 @@ func (s *SecretsService) dataKeyByLabel(ctx context.Context, label string) (stri } // 3. Store the decrypted data key into the in-memory cache. - s.dataKeyCache.add(&dataKeyCacheEntry{ - id: dataKey.Id, - label: dataKey.Label, - dataKey: decrypted, - active: dataKey.Active, - }) + s.cacheDataKey(dataKey, decrypted) return dataKey.Id, decrypted, nil } // newDataKey creates a new random data key, encrypts it and stores it into the database and cache. -func (s *SecretsService) newDataKey(ctx context.Context, label string, scope string, sess *xorm.Session) (string, []byte, error) { +func (s *SecretsService) newDataKey(ctx context.Context, label string, scope string) (string, []byte, error) { // 1. Create new data key. dataKey, err := newRandomDataKey() if err != nil { @@ -288,6 +285,7 @@ func (s *SecretsService) newDataKey(ctx context.Context, label string, scope str // 3. Store its encrypted value into the DB. id := util.GenerateShortUID() + dbDataKey := secrets.DataKey{ Active: true, Id: id, @@ -297,24 +295,11 @@ func (s *SecretsService) newDataKey(ctx context.Context, label string, scope str Scope: scope, } - if sess == nil { - err = s.store.CreateDataKey(ctx, &dbDataKey) - } else { - err = s.store.CreateDataKeyWithDBSession(ctx, &dbDataKey, sess) - } - + err = s.store.CreateDataKey(ctx, &dbDataKey) if err != nil { return "", nil, err } - // 4. Store the decrypted data key into the in-memory cache. - s.dataKeyCache.add(&dataKeyCacheEntry{ - id: id, - label: label, - dataKey: dataKey, - active: true, - }) - return id, dataKey, nil } @@ -388,13 +373,9 @@ func (s *SecretsService) Decrypt(ctx context.Context, payload []byte) ([]byte, e } func (s *SecretsService) EncryptJsonData(ctx context.Context, kv map[string]string, opt secrets.EncryptionOptions) (map[string][]byte, error) { - return s.EncryptJsonDataWithDBSession(ctx, kv, opt, nil) -} - -func (s *SecretsService) EncryptJsonDataWithDBSession(ctx context.Context, kv map[string]string, opt secrets.EncryptionOptions, sess *xorm.Session) (map[string][]byte, error) { encrypted := make(map[string][]byte) for key, value := range kv { - encryptedData, err := s.EncryptWithDBSession(ctx, []byte(value), opt, sess) + encryptedData, err := s.Encrypt(ctx, []byte(value), opt) if err != nil { return nil, err } @@ -457,12 +438,7 @@ func (s *SecretsService) dataKeyById(ctx context.Context, id string) ([]byte, er } // 3. Store the decrypted data key into the in-memory cache. - s.dataKeyCache.add(&dataKeyCacheEntry{ - id: dataKey.Id, - label: dataKey.Label, - dataKey: decrypted, - active: dataKey.Active, - }) + s.cacheDataKey(dataKey, decrypted) return decrypted, nil } @@ -547,3 +523,51 @@ func (s *SecretsService) Run(ctx context.Context) error { } } } + +// Caching a data key is tricky, because at SecretsService level we cannot guarantee +// that a newly created data key has actually been persisted, depending on the different +// use cases that rely on SecretsService encryption and different database engines that +// we have support for, because the data key creation may have happened within a DB TX, +// that may fail afterwards. +// +// Therefore, if we cache a data key that hasn't been persisted with success (and won't), +// and later that one is used for a encryption operation (aside from the DB TX that created +// it), we may end up with data encrypted by a non-persisted data key, which could end up +// in (unrecoverable) data corruption. +// +// So, we cache the data key by id and/or by label, depending on the data key's lifetime, +// assuming that a data key older than a "caution period" should have been persisted. +// +// Look at the comments inline for further details. +// You can also take a look at the issue below for more context: +// https://github.com/grafana/grafana-enterprise/issues/4252 +func (s *SecretsService) cacheDataKey(dataKey *secrets.DataKey, decrypted []byte) { + // First, we cache the data key by id, because cache "by id" is + // only used by decrypt operations, so no risk of corrupting data. + entry := &dataKeyCacheEntry{ + id: dataKey.Id, + label: dataKey.Label, + dataKey: decrypted, + active: dataKey.Active, + } + + s.dataKeyCache.addById(entry) + + // Then, we cache the data key by label, ONLY if data key's lifetime + // is longer than a certain "caution period", because cache "by label" + // is used (only) by encrypt operations, and we want to ensure that + // no data key is cached for encryption ops before being persisted. + + const cautionPeriod = 10 * time.Minute + // We consider a "caution period" of 10m to be long enough for any database + // transaction that implied a data key creation to have finished successfully. + // + // Therefore, we consider that if we fetch a data key from the database, + // more than 10m later than its creation, it should have been actually + // persisted - i.e. the transaction that created it is no longer running. + + nowMinusCautionPeriod := now().Add(-cautionPeriod) + if dataKey.Created.Before(nowMinusCautionPeriod) { + s.dataKeyCache.addByLabel(entry) + } +} diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 098cfa13135..a08065826e7 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -2,6 +2,7 @@ package manager import ( "context" + "errors" "testing" "time" @@ -17,12 +18,14 @@ import ( "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) func TestSecretsService_EnvelopeEncryption(t *testing.T) { - store := database.ProvideSecretsStore(db.InitTestDB(t)) + testDB := db.InitTestDB(t) + store := database.ProvideSecretsStore(testDB) svc := SetupTestService(t, store) ctx := context.Background() @@ -82,7 +85,8 @@ func TestSecretsService_EnvelopeEncryption(t *testing.T) { } func TestSecretsService_DataKeys(t *testing.T) { - store := database.ProvideSecretsStore(db.InitTestDB(t)) + testDB := db.InitTestDB(t) + store := database.ProvideSecretsStore(testDB) ctx := context.Background() dataKey := &secrets.DataKey{ @@ -160,7 +164,8 @@ func TestSecretsService_DataKeys(t *testing.T) { func TestSecretsService_UseCurrentProvider(t *testing.T) { t.Run("When encryption_provider is not specified explicitly, should use 'secretKey' as a current provider", func(t *testing.T) { - svc := SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) + testDB := db.InitTestDB(t) + svc := SetupTestService(t, database.ProvideSecretsStore(testDB)) assert.Equal(t, secrets.ProviderID("secretKey.v1"), svc.currentProviderID) }) @@ -187,7 +192,8 @@ func TestSecretsService_UseCurrentProvider(t *testing.T) { features := featuremgmt.WithFeatures() kms := newFakeKMS(osskmsproviders.ProvideService(encryptionService, settings, features)) - secretStore := database.ProvideSecretsStore(db.InitTestDB(t)) + testDB := db.InitTestDB(t) + secretStore := database.ProvideSecretsStore(testDB) secretsService, err := ProvideSecretsService( secretStore, @@ -261,8 +267,8 @@ func (f *fakeKMS) Provide() (map[secrets.ProviderID]secrets.Provider, error) { func TestSecretsService_Run(t *testing.T) { ctx := context.Background() - sql := db.InitTestDB(t) - store := database.ProvideSecretsStore(sql) + testDB := db.InitTestDB(t) + store := database.ProvideSecretsStore(testDB) svc := SetupTestService(t, store) t.Run("should stop with no error once the context's finished", func(t *testing.T) { @@ -274,16 +280,26 @@ func TestSecretsService_Run(t *testing.T) { }) t.Run("should trigger cache clean up", func(t *testing.T) { - // Encrypt to ensure there's a data encryption key generated - _, err := svc.Encrypt(ctx, []byte("grafana"), secrets.WithoutScope()) + restoreTimeNowAfterTestExec(t) + + // Encrypt to force data encryption key generation + encrypted, err := svc.Encrypt(ctx, []byte("grafana"), secrets.WithoutScope()) + require.NoError(t, err) + + // Ten minutes later (after caution period) + // Look SecretsService.cacheDataKey for more details. + now = func() time.Time { return time.Now().Add(10 * time.Minute) } + + // Decrypt to ensure data encryption key is cached + _, err = svc.Decrypt(ctx, encrypted) require.NoError(t, err) // Data encryption key cache should contain one element require.Len(t, svc.dataKeyCache.byId, 1) require.Len(t, svc.dataKeyCache.byLabel, 1) - t.Cleanup(func() { now = time.Now }) - now = func() time.Time { return time.Now().Add(10 * time.Minute) } + // Twenty minutes later (after caution period + cache ttl) + now = func() time.Time { return time.Now().Add(20 * time.Minute) } ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() @@ -301,8 +317,8 @@ func TestSecretsService_Run(t *testing.T) { func TestSecretsService_ReEncryptDataKeys(t *testing.T) { ctx := context.Background() - sql := db.InitTestDB(t) - store := database.ProvideSecretsStore(sql) + testDB := db.InitTestDB(t) + store := database.ProvideSecretsStore(testDB) svc := SetupTestService(t, store) // Encrypt to generate data encryption key @@ -326,6 +342,12 @@ func TestSecretsService_ReEncryptDataKeys(t *testing.T) { }) t.Run("data keys cache should be invalidated", func(t *testing.T) { + restoreTimeNowAfterTestExec(t) + + // Ten minutes later (after caution period) + // Look SecretsService.cacheDataKey for more details. + now = func() time.Time { return time.Now().Add(10 * time.Minute) } + // Decrypt to ensure data key is cached _, err := svc.Decrypt(ctx, ciphertext) require.NoError(t, err) @@ -342,7 +364,8 @@ func TestSecretsService_ReEncryptDataKeys(t *testing.T) { func TestSecretsService_Decrypt(t *testing.T) { ctx := context.Background() - store := database.ProvideSecretsStore(db.InitTestDB(t)) + testDB := db.InitTestDB(t) + store := database.ProvideSecretsStore(testDB) t.Run("empty payload should fail", func(t *testing.T) { svc := SetupTestService(t, store) @@ -401,3 +424,137 @@ func TestSecretsService_Decrypt(t *testing.T) { assert.Equal(t, []byte("grafana"), decrypted) }) } + +func TestIntegration_SecretsService(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + someData := []byte(`some-data`) + + tcs := map[string]func(*testing.T, *sqlstore.SQLStore, *SecretsService){ + "regular": func(t *testing.T, _ *sqlstore.SQLStore, svc *SecretsService) { + // We encrypt some data normally, no transactions implied. + _, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + }, + "within successful InTransaction": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NoError(t, store.InTransaction(ctx, func(ctx context.Context) error { + // We encrypt some data within a transaction that shares the db session. + _, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // And the transition succeeds. + return nil + })) + }, + "within unsuccessful InTransaction": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NotNil(t, store.InTransaction(ctx, func(ctx context.Context) error { + // We encrypt some data within a transaction that shares the db session. + _, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // But the transaction fails. + return errors.New("error") + })) + }, + "within unsuccessful InTransaction (plus forced db fetch)": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NotNil(t, store.InTransaction(ctx, func(ctx context.Context) error { + // We encrypt some data within a transaction that shares the db session. + encrypted, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // At this point the data key is not cached yet because + // the transaction haven't been committed yet, + // and won't, so we do a decrypt operation within the + // transaction to force the data key to be + // (potentially) cached (it shouldn't to prevent issues). + decrypted, err := svc.Decrypt(ctx, encrypted) + require.NoError(t, err) + assert.Equal(t, someData, decrypted) + + // But the transaction fails. + return errors.New("error") + })) + }, + "within successful WithTransactionalDbSession": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NoError(t, store.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // We encrypt some data within a transaction that does not share the db session. + _, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // And the transition succeeds. + return nil + })) + }, + "within unsuccessful WithTransactionalDbSession": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NotNil(t, store.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // We encrypt some data within a transaction that does not share the db session. + _, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // But the transaction fails. + return errors.New("error") + })) + }, + "within unsuccessful WithTransactionalDbSession (plus forced db fetch)": func(t *testing.T, store *sqlstore.SQLStore, svc *SecretsService) { + require.NotNil(t, store.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // We encrypt some data within a transaction that does not share the db session. + encrypted, err := svc.Encrypt(ctx, someData, secrets.WithoutScope()) + require.NoError(t, err) + + // At this point the data key is not cached yet because + // the transaction haven't been committed yet, + // and won't, so we do a decrypt operation within the + // transaction to force the data key to be + // (potentially) cached (it shouldn't to prevent issues). + decrypted, err := svc.Decrypt(ctx, encrypted) + require.NoError(t, err) + assert.Equal(t, someData, decrypted) + + // But the transaction fails. + return errors.New("error") + })) + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + testDB := db.InitTestDB(t) + svc := SetupTestService(t, database.ProvideSecretsStore(testDB)) + + // Here's what actually matters and varies on each test: look at the test case name. + // + // For historical reasons, and in an old implementation, when a successful encryption + // operation happened within an unsuccessful transaction, the data key was used to be + // cached in memory for the next encryption operations, which caused some data to be + // encrypted with a data key that haven't actually been persisted into the database. + tc(t, testDB, svc) + // Therefore, the data encrypted after this point, become unrecoverable after a restart. + // So, the different test cases here are there to prevent that from happening again + // in the future, whatever it is what happens. + + // So, we proceed with an encryption operation: + toEncrypt := []byte(`data-to-encrypt`) + encrypted, err := svc.Encrypt(ctx, toEncrypt, secrets.WithoutScope()) + require.NoError(t, err) + + // We simulate an instance restart. So, there's no data in the in-memory cache. + svc.dataKeyCache.flush() + + // And then, we MUST still be able to decrypt the previously encrypted data: + decrypted, err := svc.Decrypt(ctx, encrypted) + require.NoError(t, err) + assert.Equal(t, toEncrypt, decrypted) + }) + } +} + +// Use this function at the beginning of those tests +// that manipulates 'now', so it'll leave it in a +// correct state once test execution finishes. +func restoreTimeNowAfterTestExec(t *testing.T) { + t.Helper() + t.Cleanup(func() { now = time.Now }) +} diff --git a/pkg/services/secrets/migrator/reencrypt.go b/pkg/services/secrets/migrator/reencrypt.go index abc465e6bf6..c976253ff23 100644 --- a/pkg/services/secrets/migrator/reencrypt.go +++ b/pkg/services/secrets/migrator/reencrypt.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" ) func (s simpleSecret) reencrypt(ctx context.Context, secretsSrv *manager.SecretsService, sqlStore db.DB) bool { @@ -32,21 +33,24 @@ func (s simpleSecret) reencrypt(ctx context.Context, secretsSrv *manager.Secrets continue } - err := sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := sqlStore.InTransaction(ctx, func(ctx context.Context) error { decrypted, err := secretsSrv.Decrypt(ctx, row.Secret) if err != nil { logger.Warn("Could not decrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) return err } - encrypted, err := secretsSrv.EncryptWithDBSession(ctx, decrypted, secrets.WithoutScope(), sess.Session) + encrypted, err := secretsSrv.Encrypt(ctx, decrypted, secrets.WithoutScope()) if err != nil { logger.Warn("Could not encrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) return err } updateSQL := fmt.Sprintf("UPDATE %s SET %s = ?, updated = ? WHERE id = ?", s.tableName, s.columnName) - if _, err = sess.Exec(updateSQL, encrypted, nowInUTC(), row.Id); err != nil { + if err = sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Exec(updateSQL, encrypted, nowInUTC(), row.Id) + return err + }); err != nil { logger.Warn("Could not update secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) return err } @@ -88,7 +92,7 @@ func (s b64Secret) reencrypt(ctx context.Context, secretsSrv *manager.SecretsSer continue } - err := sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := sqlStore.InTransaction(ctx, func(ctx context.Context) error { decoded, err := s.encoding.DecodeString(row.Secret) if err != nil { logger.Warn("Could not decode base64-encoded secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) @@ -101,22 +105,23 @@ func (s b64Secret) reencrypt(ctx context.Context, secretsSrv *manager.SecretsSer return err } - encrypted, err := secretsSrv.EncryptWithDBSession(ctx, decrypted, secrets.WithoutScope(), sess.Session) + encrypted, err := secretsSrv.Encrypt(ctx, decrypted, secrets.WithoutScope()) if err != nil { logger.Warn("Could not encrypt secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) return err } - encoded := s.encoding.EncodeToString(encrypted) - if s.hasUpdatedColumn { - updateSQL := fmt.Sprintf("UPDATE %s SET %s = ?, updated = ? WHERE id = ?", s.tableName, s.columnName) - _, err = sess.Exec(updateSQL, encoded, nowInUTC(), row.Id) - } else { - updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) - _, err = sess.Exec(updateSQL, encoded, row.Id) - } - - if err != nil { + if err = sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) (err error) { + encoded := s.encoding.EncodeToString(encrypted) + if s.hasUpdatedColumn { + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ?, updated = ? WHERE id = ?", s.tableName, s.columnName) + _, err = sess.Exec(updateSQL, encoded, nowInUTC(), row.Id) + } else { + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) + _, err = sess.Exec(updateSQL, encoded, row.Id) + } + return + }); err != nil { logger.Warn("Could not update secret while re-encrypting it", "table", s.tableName, "id", row.Id, "error", err) return err } @@ -158,7 +163,7 @@ func (s jsonSecret) reencrypt(ctx context.Context, secretsSrv *manager.SecretsSe continue } - err := sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := sqlStore.InTransaction(ctx, func(ctx context.Context) error { decrypted, err := secretsSrv.DecryptJsonData(ctx, row.SecureJsonData) if err != nil { logger.Warn("Could not decrypt secrets while re-encrypting them", "table", s.tableName, "id", row.Id, "error", err) @@ -170,13 +175,16 @@ func (s jsonSecret) reencrypt(ctx context.Context, secretsSrv *manager.SecretsSe Updated string }{Updated: nowInUTC()} - toUpdate.SecureJsonData, err = secretsSrv.EncryptJsonDataWithDBSession(ctx, decrypted, secrets.WithoutScope(), sess.Session) + toUpdate.SecureJsonData, err = secretsSrv.EncryptJsonData(ctx, decrypted, secrets.WithoutScope()) if err != nil { logger.Warn("Could not re-encrypt secrets", "table", s.tableName, "id", row.Id, "error", err) return err } - if _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate); err != nil { + if err := sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate) + return err + }); err != nil { logger.Warn("Could not update secrets while re-encrypting them", "table", s.tableName, "id", row.Id, "error", err) return err } @@ -217,7 +225,7 @@ func (s alertingSecret) reencrypt(ctx context.Context, secretsSrv *manager.Secre for _, result := range results { result := result - err := sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := sqlStore.InTransaction(ctx, func(ctx context.Context) error { postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration)) if err != nil { logger.Warn("Could not load alert_configuration while re-encrypting it", "id", result.Id, "error", err) @@ -239,7 +247,7 @@ func (s alertingSecret) reencrypt(ctx context.Context, secretsSrv *manager.Secre return err } - reencrypted, err := secretsSrv.EncryptWithDBSession(ctx, decrypted, secrets.WithoutScope(), sess.Session) + reencrypted, err := secretsSrv.Encrypt(ctx, decrypted, secrets.WithoutScope()) if err != nil { logger.Warn("Could not re-encrypt alert_configuration secret", "id", result.Id, "key", k, "error", err) return err @@ -257,7 +265,10 @@ func (s alertingSecret) reencrypt(ctx context.Context, secretsSrv *manager.Secre } result.AlertmanagerConfiguration = string(marshalled) - if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil { + if err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result) + return err + }); err != nil { logger.Warn("Could not update alert_configuration secret while re-encrypting it", "id", result.Id, "error", err) return err } diff --git a/pkg/services/secrets/secrets.go b/pkg/services/secrets/secrets.go index 4b4a554406f..e3fb098732e 100644 --- a/pkg/services/secrets/secrets.go +++ b/pkg/services/secrets/secrets.go @@ -5,8 +5,6 @@ import ( "fmt" "strings" "time" - - "xorm.io/xorm" ) // Service is an envelope encryption service in charge of encrypting/decrypting secrets. @@ -36,7 +34,6 @@ type Store interface { GetCurrentDataKey(ctx context.Context, label string) (*DataKey, error) GetAllDataKeys(ctx context.Context) ([]*DataKey, error) CreateDataKey(ctx context.Context, dataKey *DataKey) error - CreateDataKeyWithDBSession(ctx context.Context, dataKey *DataKey, sess *xorm.Session) error DisableDataKeys(ctx context.Context) error DeleteDataKey(ctx context.Context, id string) error ReEncryptDataKeys(ctx context.Context, providers map[ProviderID]Provider, currProvider ProviderID) error diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index fc20c400a3a..d48ee7efb1c 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -11,9 +11,9 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" @@ -117,7 +117,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints() { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) CreateServiceAccount(c *contextmodel.ReqContext) response.Response { cmd := serviceaccounts.CreateServiceAccountForm{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) @@ -171,7 +171,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *contextmodel.ReqContext) response.Response { scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) @@ -218,7 +218,7 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (api *ServiceAccountsAPI) UpdateServiceAccount(c *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) UpdateServiceAccount(c *contextmodel.ReqContext) response.Response { scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) @@ -286,7 +286,7 @@ func (api *ServiceAccountsAPI) validateRole(r *org.RoleType, orgRole *org.RoleTy // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *contextmodel.ReqContext) response.Response { scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service account ID is invalid", err) @@ -310,7 +310,7 @@ func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) resp // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmodel.ReqContext) response.Response { ctx := c.Req.Context() perPage := c.QueryInt("perpage") if perPage <= 0 { @@ -365,7 +365,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqC } // GET /api/serviceaccounts/migrationstatus -func (api *ServiceAccountsAPI) GetAPIKeysMigrationStatus(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) GetAPIKeysMigrationStatus(ctx *contextmodel.ReqContext) response.Response { upgradeStatus, err := api.service.GetAPIKeysMigrationStatus(ctx.Req.Context(), ctx.OrgID) if err != nil { return response.Error(http.StatusInternalServerError, "Internal server error", err) @@ -374,7 +374,7 @@ func (api *ServiceAccountsAPI) GetAPIKeysMigrationStatus(ctx *models.ReqContext) } // POST /api/serviceaccounts/hideapikeys -func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *contextmodel.ReqContext) response.Response { if err := api.service.HideApiKeysTab(ctx.Req.Context(), ctx.OrgID); err != nil { return response.Error(http.StatusInternalServerError, "Internal server error", err) } @@ -382,7 +382,7 @@ func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *models.ReqContext) response.R } // POST /api/serviceaccounts/migrate -func (api *ServiceAccountsAPI) MigrateApiKeysToServiceAccounts(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) MigrateApiKeysToServiceAccounts(ctx *contextmodel.ReqContext) response.Response { if err := api.service.MigrateApiKeysToServiceAccounts(ctx.Req.Context(), ctx.OrgID); err != nil { return response.Error(http.StatusInternalServerError, "Internal server error", err) } @@ -391,7 +391,7 @@ func (api *ServiceAccountsAPI) MigrateApiKeysToServiceAccounts(ctx *models.ReqCo } // POST /api/serviceaccounts/migrate/:keyId -func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *contextmodel.ReqContext) response.Response { keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Key ID is invalid", err) @@ -405,7 +405,7 @@ func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *models.ReqContext) r } // POST /api/serviceaccounts/revert/:keyId -func (api *ServiceAccountsAPI) RevertApiKey(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) RevertApiKey(ctx *contextmodel.ReqContext) response.Response { keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "key ID is invalid", err) @@ -421,7 +421,7 @@ func (api *ServiceAccountsAPI) RevertApiKey(ctx *models.ReqContext) response.Res return response.Success("reverted service account to API key") } -func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata { +func (api *ServiceAccountsAPI) getAccessControlMetadata(c *contextmodel.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata { if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") { return map[string]accesscontrol.Metadata{} } diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index 6be829ccad5..5867cc7734f 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/web" @@ -66,7 +66,7 @@ const sevenDaysAhead = 7 * 24 * time.Hour // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) ListTokens(ctx *contextmodel.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) @@ -127,7 +127,7 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo // 404: notFoundError // 409: conflictError // 500: internalServerError -func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) CreateToken(c *contextmodel.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) @@ -210,7 +210,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Response { +func (api *ServiceAccountsAPI) DeleteToken(c *contextmodel.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go index 8f9d1cc9349..42420dd2e31 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go @@ -8,22 +8,21 @@ import ( "xorm.io/xorm" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) -var dashboardPermissionTranslation = map[models.PermissionType][]string{ - models.PERMISSION_VIEW: { +var dashboardPermissionTranslation = map[dashboards.PermissionType][]string{ + dashboards.PERMISSION_VIEW: { dashboards.ActionDashboardsRead, }, - models.PERMISSION_EDIT: { + dashboards.PERMISSION_EDIT: { dashboards.ActionDashboardsRead, dashboards.ActionDashboardsWrite, dashboards.ActionDashboardsDelete, }, - models.PERMISSION_ADMIN: { + dashboards.PERMISSION_ADMIN: { dashboards.ActionDashboardsRead, dashboards.ActionDashboardsWrite, dashboards.ActionDashboardsCreate, @@ -33,17 +32,17 @@ var dashboardPermissionTranslation = map[models.PermissionType][]string{ }, } -var folderPermissionTranslation = map[models.PermissionType][]string{ - models.PERMISSION_VIEW: append(dashboardPermissionTranslation[models.PERMISSION_VIEW], []string{ +var folderPermissionTranslation = map[dashboards.PermissionType][]string{ + dashboards.PERMISSION_VIEW: append(dashboardPermissionTranslation[dashboards.PERMISSION_VIEW], []string{ dashboards.ActionFoldersRead, }...), - models.PERMISSION_EDIT: append(dashboardPermissionTranslation[models.PERMISSION_EDIT], []string{ + dashboards.PERMISSION_EDIT: append(dashboardPermissionTranslation[dashboards.PERMISSION_EDIT], []string{ dashboards.ActionDashboardsCreate, dashboards.ActionFoldersRead, dashboards.ActionFoldersWrite, dashboards.ActionFoldersDelete, }...), - models.PERMISSION_ADMIN: append(dashboardPermissionTranslation[models.PERMISSION_ADMIN], []string{ + dashboards.PERMISSION_ADMIN: append(dashboardPermissionTranslation[dashboards.PERMISSION_ADMIN], []string{ dashboards.ActionFoldersRead, dashboards.ActionFoldersWrite, dashboards.ActionFoldersDelete, @@ -98,9 +97,9 @@ func (m dashboardPermissionsMigrator) Exec(sess *xorm.Session, migrator *migrato return nil } -func (m dashboardPermissionsMigrator) migratePermissions(dashboards []dashboard, aclMap map[int64][]dashboards.DashboardACL, migrator *migrator.Migrator) error { +func (m dashboardPermissionsMigrator) migratePermissions(dashes []dashboard, aclMap map[int64][]dashboards.DashboardACL, migrator *migrator.Migrator) error { permissionMap := map[int64]map[string][]*ac.Permission{} - for _, d := range dashboards { + for _, d := range dashes { if d.ID == -1 { continue } @@ -112,11 +111,11 @@ func (m dashboardPermissionsMigrator) migratePermissions(dashboards []dashboard, if (d.IsFolder || d.FolderID == 0) && len(acls) == 0 && !d.HasAcl { permissionMap[d.OrgID]["managed:builtins:editor:permissions"] = append( permissionMap[d.OrgID]["managed:builtins:editor:permissions"], - m.mapPermission(d.ID, models.PERMISSION_EDIT, d.IsFolder)..., + m.mapPermission(d.ID, dashboards.PERMISSION_EDIT, d.IsFolder)..., ) permissionMap[d.OrgID]["managed:builtins:viewer:permissions"] = append( permissionMap[d.OrgID]["managed:builtins:viewer:permissions"], - m.mapPermission(d.ID, models.PERMISSION_VIEW, d.IsFolder)..., + m.mapPermission(d.ID, dashboards.PERMISSION_VIEW, d.IsFolder)..., ) } else { for _, a := range deduplicateAcl(acls) { @@ -195,7 +194,7 @@ func (m dashboardPermissionsMigrator) setPermissions(allRoles []*ac.Role, permis return nil } -func (m dashboardPermissionsMigrator) mapPermission(id int64, p models.PermissionType, isFolder bool) []*ac.Permission { +func (m dashboardPermissionsMigrator) mapPermission(id int64, p dashboards.PermissionType, isFolder bool) []*ac.Permission { if isFolder { actions := folderPermissionTranslation[p] scope := dashboards.ScopeFoldersProvider.GetResourceScope(strconv.FormatInt(id, 10)) @@ -559,15 +558,15 @@ func (m *managedFolderAlertActionsRepeatMigrator) Exec(sess *xorm.Session, mg *m } func hasFolderAdmin(permissions []ac.Permission) bool { - return hasActions(folderPermissionTranslation[models.PERMISSION_ADMIN], permissions) + return hasActions(folderPermissionTranslation[dashboards.PERMISSION_ADMIN], permissions) } func hasFolderEdit(permissions []ac.Permission) bool { - return hasActions(folderPermissionTranslation[models.PERMISSION_EDIT], permissions) + return hasActions(folderPermissionTranslation[dashboards.PERMISSION_EDIT], permissions) } func hasFolderView(permissions []ac.Permission) bool { - return hasActions(folderPermissionTranslation[models.PERMISSION_VIEW], permissions) + return hasActions(folderPermissionTranslation[dashboards.PERMISSION_VIEW], permissions) } func hasActions(actions []string, permissions []ac.Permission) bool { diff --git a/pkg/services/sqlstore/migrations/accesscontrol/team_membership.go b/pkg/services/sqlstore/migrations/accesscontrol/team_membership.go index c9e9f29c207..c2252b17860 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/team_membership.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/team_membership.go @@ -7,8 +7,8 @@ import ( "xorm.io/xorm" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/team" @@ -64,12 +64,12 @@ func (p *teamPermissionMigrator) setRolePermissions(roleID int64, permissions [] } // mapPermissionToRBAC translates the legacy membership (Member or Admin) into RBAC permissions -func (p *teamPermissionMigrator) mapPermissionToRBAC(permission models.PermissionType, teamID int64) []accesscontrol.Permission { +func (p *teamPermissionMigrator) mapPermissionToRBAC(permission dashboards.PermissionType, teamID int64) []accesscontrol.Permission { teamIDScope := accesscontrol.Scope("teams", "id", strconv.FormatInt(teamID, 10)) switch permission { case 0: return []accesscontrol.Permission{{Action: "teams:read", Scope: teamIDScope}} - case models.PERMISSION_ADMIN: + case dashboards.PERMISSION_ADMIN: return []accesscontrol.Permission{ {Action: "teams:delete", Scope: teamIDScope}, {Action: "teams:read", Scope: teamIDScope}, @@ -210,7 +210,7 @@ func (p *teamPermissionMigrator) generateAssociatedPermissions(teamMemberships [ // Downgrade team permissions if needed: // only admins or editors (when editorsCanAdmin option is enabled) // can access team administration endpoints - if m.Permission == models.PERMISSION_ADMIN { + if m.Permission == dashboards.PERMISSION_ADMIN { if userRolesByOrg[m.OrgID][m.UserID] == string(org.RoleViewer) || (userRolesByOrg[m.OrgID][m.UserID] == string(org.RoleEditor) && !p.editorsCanAdmin) { m.Permission = 0 diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go index a1fb2e3d72a..0501f62580c 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go @@ -9,8 +9,8 @@ import ( "xorm.io/xorm" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" acmig "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" @@ -353,7 +353,7 @@ func setupTeams(t *testing.T, x *xorm.Engine) { TeamID: 1, UserID: 2, External: false, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: now, Updated: now, }, @@ -363,7 +363,7 @@ func setupTeams(t *testing.T, x *xorm.Engine) { TeamID: 1, UserID: 3, External: false, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: now, Updated: now, }, @@ -373,7 +373,7 @@ func setupTeams(t *testing.T, x *xorm.Engine) { TeamID: 1, UserID: 4, External: false, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, Created: now, Updated: now, }, diff --git a/pkg/services/sqlstore/migrations/entity_store_mig.go b/pkg/services/sqlstore/migrations/entity_store_mig.go index 0d4d0e21fba..69d214d3e25 100644 --- a/pkg/services/sqlstore/migrations/entity_store_mig.go +++ b/pkg/services/sqlstore/migrations/entity_store_mig.go @@ -85,7 +85,7 @@ func addEntityStoreMigrations(mg *migrator.Migrator) { }, Indices: []*migrator.Index{ {Cols: []string{"tenant_id", "uid"}, Type: migrator.UniqueIndex}, - {Cols: []string{"tenant_id", "slug_path"}, Type: migrator.UniqueIndex}, + // {Cols: []string{"tenant_id", "slug_path"}, Type: migrator.UniqueIndex}, }, }) @@ -111,9 +111,9 @@ func addEntityStoreMigrations(mg *migrator.Migrator) { {Name: "parent_grn", Type: migrator.DB_NVarchar, Length: grnLength, Nullable: true}, // Address (defined in the body, not resolved, may be invalid and change) - {Name: "kind", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, + {Name: "family", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "type", Type: migrator.DB_NVarchar, Length: 255, Nullable: true}, - {Name: "uid", Type: migrator.DB_NVarchar, Length: 1024, Nullable: true}, + {Name: "id", Type: migrator.DB_NVarchar, Length: 1024, Nullable: true}, // Runtime calcs (will depend on the system state) {Name: "resolved_ok", Type: migrator.DB_Bool, Nullable: false}, @@ -123,7 +123,8 @@ func addEntityStoreMigrations(mg *migrator.Migrator) { }, Indices: []*migrator.Index{ {Cols: []string{"grn"}, Type: migrator.IndexType}, - {Cols: []string{"kind"}, Type: migrator.IndexType}, + {Cols: []string{"family"}, Type: migrator.IndexType}, + {Cols: []string{"type"}, Type: migrator.IndexType}, {Cols: []string{"resolved_to"}, Type: migrator.IndexType}, {Cols: []string{"parent_grn"}, Type: migrator.IndexType}, }, @@ -192,7 +193,7 @@ func addEntityStoreMigrations(mg *migrator.Migrator) { // Migration cleanups: given that this is a complex setup // that requires a lot of testing before we are ready to push out of dev // this script lets us easy wipe previous changes and initialize clean tables - suffix := " (v31)" // change this when we want to wipe and reset the object tables + suffix := " (v010)" // change this when we want to wipe and reset the object tables mg.AddMigration("EntityStore init: cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` DELETE FROM migration_log WHERE migration_id LIKE 'EntityStore init%'; `))) diff --git a/pkg/services/sqlstore/migrations/ualert/permissions.go b/pkg/services/sqlstore/migrations/ualert/permissions.go index 08e153056b6..271a8b2f5fc 100644 --- a/pkg/services/sqlstore/migrations/ualert/permissions.go +++ b/pkg/services/sqlstore/migrations/ualert/permissions.go @@ -7,13 +7,11 @@ import ( "xorm.io/xorm" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util" - - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/models" ) type roleType string @@ -206,7 +204,7 @@ func (m *folderHelper) setACL(orgID int64, dashboardID int64, items []*dashboard seen := make(map[keyType]struct{}, len(items)) for _, item := range items { if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing + return dashboards.ErrDashboardACLInfoMissing } // ignore duplicate user permissions diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index 24f12167a29..6598f094149 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -3,6 +3,8 @@ package ualert import ( "fmt" + "xorm.io/xorm" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -12,34 +14,45 @@ const DefaultFieldMaxLength = 190 // UIDMaxLength is the standard size for fields that contain UIDs. const UIDMaxLength = 40 -// AddMigration defines database migrations. +// AddTablesMigrations defines database migrations that affect Grafana Alerting tables. func AddTablesMigrations(mg *migrator.Migrator) { - AddAlertDefinitionMigrations(mg, 60) - AddAlertDefinitionVersionMigrations(mg) - // Create alert_instance table - AlertInstanceMigration(mg) - - // Create alert_rule - AddAlertRuleMigrations(mg, 60) - AddAlertRuleVersionMigrations(mg) - - // Create Alertmanager configurations - AddAlertmanagerConfigMigrations(mg) - - // Create Admin Configuration - AddAlertAdminConfigMigrations(mg) - - // Create provisioning data table - AddProvisioningMigrations(mg) - - AddAlertImageMigrations(mg) - - AddAlertmanagerConfigHistoryMigrations(mg) - ExtractAlertmanagerConfigurationHistoryMigration(mg) + // Migrations are meant to be immutable, any modifications to table structure + // should come in the form of a new migration appended to the end of AddTablesMigrations + // instead of modifying an existing one. This ensure that tables are modified in a consistent and correct order. + historicalTableMigrations(mg) } -// AddAlertDefinitionMigrations should not be modified. -func AddAlertDefinitionMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) { +// historicalTableMigrations contains those migrations that existed prior to creating the improved messaging around migration immutability. +func historicalTableMigrations(mg *migrator.Migrator) { + // DO NOT EDIT + addAlertDefinitionMigrations(mg, 60) + addAlertDefinitionVersionMigrations(mg) + // Create alert_instance table + alertInstanceMigration(mg) + + // Create alert_rule + addAlertRuleMigrations(mg, 60) + addAlertRuleVersionMigrations(mg) + + // Create Alertmanager configurations + addAlertmanagerConfigMigrations(mg) + + // Create Admin Configuration + addAlertAdminConfigMigrations(mg) + + // Create provisioning data table + addProvisioningMigrations(mg) + + addAlertImageMigrations(mg) + + addAlertmanagerConfigHistoryMigrations(mg) + + extractAlertmanagerConfigurationHistoryMigration(mg) +} + +// addAlertDefinitionMigrations should not be modified. +func addAlertDefinitionMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) { + // DO NOT EDIT mg.AddMigration("delete alert_definition table", migrator.NewDropTableMigration("alert_definition")) alertDefinition := migrator.Table{ @@ -87,8 +100,9 @@ func AddAlertDefinitionMigrations(mg *migrator.Migrator, defaultIntervalSeconds mg.AddMigration("drop alert_definition table", migrator.NewDropTableMigration("alert_definition")) } -// AddAlertDefinitionMigrations should not be modified. -func AddAlertDefinitionVersionMigrations(mg *migrator.Migrator) { +// addAlertDefinitionMigrations should not be modified. +func addAlertDefinitionVersionMigrations(mg *migrator.Migrator) { + // DO NOT EDIT mg.AddMigration("delete alert_definition_version table", migrator.NewDropTableMigration("alert_definition_version")) alertDefinitionVersion := migrator.Table{ @@ -120,7 +134,8 @@ func AddAlertDefinitionVersionMigrations(mg *migrator.Migrator) { mg.AddMigration("drop alert_definition_version table", migrator.NewDropTableMigration("alert_definition_version")) } -func AlertInstanceMigration(mg *migrator.Migrator) { +func alertInstanceMigration(mg *migrator.Migrator) { + // DO NOT EDIT alertInstance := migrator.Table{ Name: "alert_instance", Columns: []*migrator.Column{ @@ -171,7 +186,8 @@ func AlertInstanceMigration(mg *migrator.Migrator) { })) } -func AddAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) { +func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) { + // DO NOT EDIT alertRule := migrator.Table{ Name: "alert_rule", Columns: []*migrator.Column{ @@ -260,9 +276,20 @@ func AddAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) Default: "1", }, )) + + mg.AddMigration("add is_paused column to alert_rule table", migrator.NewAddColumnMigration( + alertRule, + &migrator.Column{ + Name: "is_paused", + Type: migrator.DB_Bool, + Nullable: false, + Default: "false", + }, + )) } -func AddAlertRuleVersionMigrations(mg *migrator.Migrator) { +func addAlertRuleVersionMigrations(mg *migrator.Migrator) { + // DO NOT EDIT alertRuleVersion := migrator.Table{ Name: "alert_rule_version", Columns: []*migrator.Column{ @@ -313,9 +340,20 @@ func AddAlertRuleVersionMigrations(mg *migrator.Migrator) { Default: "1", }, )) + + mg.AddMigration("add is_paused column to alert_rule_versions table", migrator.NewAddColumnMigration( + alertRuleVersion, + &migrator.Column{ + Name: "is_paused", + Type: migrator.DB_Bool, + Nullable: false, + Default: "false", + }, + )) } -func AddAlertmanagerConfigMigrations(mg *migrator.Migrator) { +func addAlertmanagerConfigMigrations(mg *migrator.Migrator) { + // DO NOT EDIT alertConfiguration := migrator.Table{ Name: "alert_configuration", Columns: []*migrator.Column{ @@ -348,7 +386,8 @@ func AddAlertmanagerConfigMigrations(mg *migrator.Migrator) { })) } -func AddAlertmanagerConfigHistoryMigrations(mg *migrator.Migrator) { +func addAlertmanagerConfigHistoryMigrations(mg *migrator.Migrator) { + // DO NOT EDIT alertConfigHistory := migrator.Table{ Name: "alert_configuration_history", Columns: []*migrator.Column{ @@ -368,7 +407,8 @@ func AddAlertmanagerConfigHistoryMigrations(mg *migrator.Migrator) { mg.AddMigration("create_alert_configuration_history_table", migrator.NewAddTableMigration(alertConfigHistory)) } -func AddAlertAdminConfigMigrations(mg *migrator.Migrator) { +func addAlertAdminConfigMigrations(mg *migrator.Migrator) { + // DO NOT EDIT adminConfiguration := migrator.Table{ Name: "ngalert_configuration", Columns: []*migrator.Column{ @@ -391,7 +431,8 @@ func AddAlertAdminConfigMigrations(mg *migrator.Migrator) { })) } -func AddProvisioningMigrations(mg *migrator.Migrator) { +func addProvisioningMigrations(mg *migrator.Migrator) { + // DO NOT EDIT provisioningTable := migrator.Table{ Name: "provenance_type", Columns: []*migrator.Column{ @@ -410,7 +451,8 @@ func AddProvisioningMigrations(mg *migrator.Migrator) { mg.AddMigration("add index to uniquify (record_key, record_type, org_id) columns", migrator.NewAddIndexMigration(provisioningTable, provisioningTable.Indices[0])) } -func AddAlertImageMigrations(mg *migrator.Migrator) { +func addAlertImageMigrations(mg *migrator.Migrator) { + // DO NOT EDIT imageTable := migrator.Table{ Name: "alert_image", Columns: []*migrator.Column{ @@ -433,3 +475,81 @@ func AddAlertImageMigrations(mg *migrator.Migrator) { Postgres("ALTER TABLE alert_image ALTER COLUMN url TYPE VARCHAR(2048);"). Mysql("ALTER TABLE alert_image MODIFY url VARCHAR(2048) NOT NULL;")) } + +func extractAlertmanagerConfigurationHistoryMigration(mg *migrator.Migrator) { + if !mg.Cfg.UnifiedAlerting.IsEnabled() { + return + } + // Since it's not always consistent as to what state the org ID indexes are in, just drop them all and rebuild from scratch. + // This is not expensive since this table is guaranteed to have a small number of rows. + mg.AddMigration("drop non-unique orgID index on alert_configuration", migrator.NewDropIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Cols: []string{"org_id"}})) + mg.AddMigration("drop unique orgID index on alert_configuration if exists", migrator.NewDropIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Type: migrator.UniqueIndex, Cols: []string{"org_id"}})) + mg.AddMigration("extract alertmanager configuration history to separate table", &extractAlertmanagerConfigurationHistory{}) + mg.AddMigration("add unique index on orgID to alert_configuration", migrator.NewAddIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Type: migrator.UniqueIndex, Cols: []string{"org_id"}})) +} + +type extractAlertmanagerConfigurationHistory struct { + migrator.MigrationBase +} + +// extractAMConfigHistoryConfigModel is the model of an alertmanager configuration row, at the time that the extractAlertmanagerConfigurationHistory migration was run. +// This is not to be used outside of the extractAlertmanagerConfigurationHistory migration. +type extractAMConfigHistoryConfigModel struct { + ID int64 `xorm:"pk autoincr 'id'"` + AlertmanagerConfiguration string + ConfigurationHash string + ConfigurationVersion string + CreatedAt int64 `xorm:"created"` + Default bool + OrgID int64 `xorm:"org_id"` +} + +func (c extractAlertmanagerConfigurationHistory) SQL(migrator.Dialect) string { + return codeMigration +} + +func (c extractAlertmanagerConfigurationHistory) Exec(sess *xorm.Session, migrator *migrator.Migrator) error { + // DO NOT EDIT + var orgs []int64 + if err := sess.Table("alert_configuration").Distinct("org_id").Find(&orgs); err != nil { + return fmt.Errorf("failed to retrieve the organizations with alerting configurations: %w", err) + } + + // Clear out the history table, just in case. It should already be empty. + if _, err := sess.Exec("DELETE FROM alert_configuration_history"); err != nil { + return fmt.Errorf("failed to clear the config history table: %w", err) + } + + for _, orgID := range orgs { + var activeConfigID int64 + has, err := sess.SQL(`SELECT MAX(id) FROM alert_configuration WHERE org_id = ?`, orgID).Get(&activeConfigID) + if err != nil { + return fmt.Errorf("failed to query active config ID for org %d: %w", orgID, err) + } + if !has { + return fmt.Errorf("we previously found a config for org, but later it was unexpectedly missing: %d", orgID) + } + + history := make([]extractAMConfigHistoryConfigModel, 0) + err = sess.Table("alert_configuration").Where("org_id = ? AND id < ?", orgID, activeConfigID).Find(&history) + if err != nil { + return fmt.Errorf("failed to query for non-active configs for org %d: %w", orgID, err) + } + + // Set the IDs back to the default, so XORM will ignore the field and auto-assign them. + for i := range history { + history[i].ID = 0 + } + + _, err = sess.Table("alert_configuration_history").InsertMulti(history) + if err != nil { + return fmt.Errorf("failed to insert historical configs for org: %d: %w", orgID, err) + } + + _, err = sess.Exec("DELETE FROM alert_configuration WHERE org_id = ? AND id < ?", orgID, activeConfigID) + if err != nil { + return fmt.Errorf("failed to evict old configurations for org after moving to history table: %d: %w", orgID, err) + } + } + return nil +} diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index d2073b9f023..29765e1ccd1 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -927,80 +927,3 @@ func (s *uidSet) generateUid() (string, error) { return "", errors.New("failed to generate UID") } - -func ExtractAlertmanagerConfigurationHistoryMigration(mg *migrator.Migrator) { - if !mg.Cfg.UnifiedAlerting.IsEnabled() { - return - } - // Since it's not always consistent as to what state the org ID indexes are in, just drop them all and rebuild from scratch. - // This is not expensive since this table is guaranteed to have a small number of rows. - mg.AddMigration("drop non-unique orgID index on alert_configuration", migrator.NewDropIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Cols: []string{"org_id"}})) - mg.AddMigration("drop unique orgID index on alert_configuration if exists", migrator.NewDropIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Type: migrator.UniqueIndex, Cols: []string{"org_id"}})) - mg.AddMigration("extract alertmanager configuration history to separate table", &extractAlertmanagerConfigurationHistory{}) - mg.AddMigration("add unique index on orgID to alert_configuration", migrator.NewAddIndexMigration(migrator.Table{Name: "alert_configuration"}, &migrator.Index{Type: migrator.UniqueIndex, Cols: []string{"org_id"}})) -} - -type extractAlertmanagerConfigurationHistory struct { - migrator.MigrationBase -} - -// extractAMConfigHistoryConfigModel is the model of an alertmanager configuration row, at the time that the extractAlertmanagerConfigurationHistory migration was run. -// This is not to be used outside of the extractAlertmanagerConfigurationHistory migration. -type extractAMConfigHistoryConfigModel struct { - ID int64 `xorm:"pk autoincr 'id'"` - AlertmanagerConfiguration string - ConfigurationHash string - ConfigurationVersion string - CreatedAt int64 `xorm:"created"` - Default bool - OrgID int64 `xorm:"org_id"` -} - -func (c extractAlertmanagerConfigurationHistory) SQL(migrator.Dialect) string { - return codeMigration -} - -func (c extractAlertmanagerConfigurationHistory) Exec(sess *xorm.Session, migrator *migrator.Migrator) error { - var orgs []int64 - if err := sess.Table("alert_configuration").Distinct("org_id").Find(&orgs); err != nil { - return fmt.Errorf("failed to retrieve the organizations with alerting configurations: %w", err) - } - - // Clear out the history table, just in case. It should already be empty. - if _, err := sess.Exec("DELETE FROM alert_configuration_history"); err != nil { - return fmt.Errorf("failed to clear the config history table: %w", err) - } - - for _, orgID := range orgs { - var activeConfigID int64 - has, err := sess.SQL(`SELECT MAX(id) FROM alert_configuration WHERE org_id = ?`, orgID).Get(&activeConfigID) - if err != nil { - return fmt.Errorf("failed to query active config ID for org %d: %w", orgID, err) - } - if !has { - return fmt.Errorf("we previously found a config for org, but later it was unexpectedly missing: %d", orgID) - } - - history := make([]extractAMConfigHistoryConfigModel, 0) - err = sess.Table("alert_configuration").Where("org_id = ? AND id < ?", orgID, activeConfigID).Find(&history) - if err != nil { - return fmt.Errorf("failed to query for non-active configs for org %d: %w", orgID, err) - } - - // Set the IDs back to the default, so XORM will ignore the field and auto-assign them. - for i := range history { - history[i].ID = 0 - } - - _, err = sess.Table("alert_configuration_history").InsertMulti(history) - if err != nil { - return fmt.Errorf("failed to insert historical configs for org: %d: %w", orgID, err) - } - - _, err = sess.Exec("DELETE FROM alert_configuration WHERE org_id = ? AND id < ?", orgID, activeConfigID) - if err != nil { - return fmt.Errorf("failed to evict old configurations for org after moving to history table: %d: %w", orgID, err) - } - } - return nil -} diff --git a/pkg/services/sqlstore/permissions/dashboard.go b/pkg/services/sqlstore/permissions/dashboard.go index 9f8a1e3c834..bb50a034061 100644 --- a/pkg/services/sqlstore/permissions/dashboard.go +++ b/pkg/services/sqlstore/permissions/dashboard.go @@ -3,7 +3,6 @@ package permissions import ( "strings" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -17,7 +16,7 @@ type DashboardPermissionFilter struct { Dialect migrator.Dialect UserId int64 OrgId int64 - PermissionLevel models.PermissionType + PermissionLevel dashboards.PermissionType } func (d DashboardPermissionFilter) Where() (string, []interface{}) { @@ -85,9 +84,9 @@ type AccessControlDashboardPermissionFilter struct { folderActions []string } -// NewAccessControlDashboardPermissionFilter creates a new AccessControlDashboardPermissionFilter that is configured with specific actions calculated based on the models.PermissionType and query type -func NewAccessControlDashboardPermissionFilter(user *user.SignedInUser, permissionLevel models.PermissionType, queryType string) AccessControlDashboardPermissionFilter { - needEdit := permissionLevel > models.PERMISSION_VIEW +// NewAccessControlDashboardPermissionFilter creates a new AccessControlDashboardPermissionFilter that is configured with specific actions calculated based on the dashboards.PermissionType and query type +func NewAccessControlDashboardPermissionFilter(user *user.SignedInUser, permissionLevel dashboards.PermissionType, queryType string) AccessControlDashboardPermissionFilter { + needEdit := permissionLevel > dashboards.PERMISSION_VIEW var folderActions []string var dashboardActions []string diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index 52b36474066..d9552da351c 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -6,9 +6,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -16,8 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestIntegration_DashboardPermissionFilter(t *testing.T) { @@ -28,7 +28,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { type testCase struct { desc string queryType string - permission models.PermissionType + permission dashboards.PermissionType permissions []accesscontrol.Permission expectedResult int } @@ -36,7 +36,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { tests := []testCase{ { desc: "Should be able to view all dashboards with wildcard scope", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, }, @@ -44,7 +44,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should be able to view all dashboards with folder wildcard scope", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersAll}, }, @@ -52,7 +52,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should be able to view a subset of dashboards with dashboard scopes", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:110"}, {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:40"}, @@ -65,7 +65,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should be able to view a subset of dashboards with dashboard action and folder scope", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:8"}, {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:10"}, @@ -74,7 +74,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should be able to view all folders with folder wildcard", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:*"}, }, @@ -82,7 +82,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should be able to view a subset folders", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:6"}, @@ -92,7 +92,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should return folders and dashboard with 'edit' permission", - permission: models.PERMISSION_EDIT, + permission: dashboards.PERMISSION_EDIT, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, {Action: dashboards.ActionDashboardsCreate, Scope: "folders:uid:3"}, @@ -103,7 +103,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should return folders that users can read alerts from", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, queryType: searchstore.TypeAlertFolder, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:3"}, @@ -115,7 +115,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { }, { desc: "Should return folders that users can read alerts when user has read wildcard", - permission: models.PERMISSION_VIEW, + permission: dashboards.PERMISSION_VIEW, queryType: searchstore.TypeAlertFolder, permissions: []accesscontrol.Permission{ {Action: dashboards.ActionFoldersRead, Scope: "*"}, diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index b2e6af1d005..d213fee5ac8 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -7,17 +7,17 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards int) { @@ -25,7 +25,7 @@ func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards in b.ResetTimer() for i := 0; i < b.N; i++ { usr := &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{1: {}}} - filter := permissions.NewAccessControlDashboardPermissionFilter(usr, models.PERMISSION_VIEW, "") + filter := permissions.NewAccessControlDashboardPermissionFilter(usr, dashboards.PERMISSION_VIEW, "") var result int err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { q, params := filter.Where() diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index 74ce0dcaeba..9d0945aac3d 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" @@ -122,7 +121,7 @@ func TestBuilder_Permissions(t *testing.T) { store := setupTestEnvironment(t) createDashboards(t, store, 0, 1, user.OrgID) - level := models.PERMISSION_EDIT + level := dashboards.PERMISSION_EDIT builder := &searchstore.Builder{ Filters: []interface{}{ diff --git a/pkg/api/stars.go b/pkg/services/star/api/api.go similarity index 71% rename from pkg/api/stars.go rename to pkg/services/star/api/api.go index 47bd698b969..ec231596e4c 100644 --- a/pkg/api/stars.go +++ b/pkg/services/star/api/api.go @@ -1,22 +1,56 @@ package api import ( + "context" "net/http" "strconv" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/web" ) -func (hs *HTTPServer) GetStars(c *models.ReqContext) response.Response { +type API struct { + starService star.Service + dashboardService dashboards.DashboardService +} + +func ProvideApi( + starService star.Service, + dashboardService dashboards.DashboardService, +) *API { + api := &API{ + starService: starService, + dashboardService: dashboardService, + } + return api +} + +func (api *API) getDashboardHelper(ctx context.Context, orgID int64, id int64, uid string) (*dashboards.Dashboard, response.Response) { + var query dashboards.GetDashboardQuery + + if len(uid) > 0 { + query = dashboards.GetDashboardQuery{UID: uid, ID: id, OrgID: orgID} + } else { + query = dashboards.GetDashboardQuery{ID: id, OrgID: orgID} + } + + result, err := api.dashboardService.GetDashboard(ctx, &query) + if err != nil { + return nil, response.Error(404, "Dashboard not found", err) + } + + return result, nil +} + +func (api *API) GetStars(c *contextmodel.ReqContext) response.Response { query := star.GetUserStarsQuery{ UserID: c.SignedInUser.UserID, } - iuserstars, err := hs.starService.GetByUser(c.Req.Context(), &query) + iuserstars, err := api.starService.GetByUser(c.Req.Context(), &query) if err != nil { return response.Error(500, "Failed to get user stars", err) } @@ -27,11 +61,11 @@ func (hs *HTTPServer) GetStars(c *models.ReqContext) response.Response { ID: dashboardId, OrgID: c.OrgID, } - err := hs.DashboardService.GetDashboard(c.Req.Context(), query) + queryResult, err := api.dashboardService.GetDashboard(c.Req.Context(), query) // Grafana admin users may have starred dashboards in multiple orgs. This will avoid returning errors when the dashboard is in another org if err == nil { - uids = append(uids, query.Result.UID) + uids = append(uids, queryResult.UID) } } return response.JSON(200, uids) @@ -51,7 +85,7 @@ func (hs *HTTPServer) GetStars(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) StarDashboard(c *models.ReqContext) response.Response { +func (api *API) StarDashboard(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Invalid dashboard ID", nil) @@ -62,7 +96,7 @@ func (hs *HTTPServer) StarDashboard(c *models.ReqContext) response.Response { return response.Error(400, "Missing dashboard id", nil) } - if err := hs.starService.Add(c.Req.Context(), &cmd); err != nil { + if err := api.starService.Add(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to star dashboard", err) } @@ -81,12 +115,12 @@ func (hs *HTTPServer) StarDashboard(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) StarDashboardByUID(c *models.ReqContext) response.Response { +func (api *API) StarDashboardByUID(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] if uid == "" { return response.Error(http.StatusBadRequest, "Invalid dashboard UID", nil) } - dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.OrgID, 0, uid) + dash, rsp := api.getDashboardHelper(c.Req.Context(), c.OrgID, 0, uid) if rsp != nil { return rsp @@ -94,7 +128,7 @@ func (hs *HTTPServer) StarDashboardByUID(c *models.ReqContext) response.Response cmd := star.StarDashboardCommand{UserID: c.UserID, DashboardID: dash.ID} - if err := hs.starService.Add(c.Req.Context(), &cmd); err != nil { + if err := api.starService.Add(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to star dashboard", err) } @@ -117,7 +151,7 @@ func (hs *HTTPServer) StarDashboardByUID(c *models.ReqContext) response.Response // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UnstarDashboard(c *models.ReqContext) response.Response { +func (api *API) UnstarDashboard(c *contextmodel.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "Invalid dashboard ID", nil) @@ -128,7 +162,7 @@ func (hs *HTTPServer) UnstarDashboard(c *models.ReqContext) response.Response { return response.Error(400, "Missing dashboard id", nil) } - if err := hs.starService.Delete(c.Req.Context(), &cmd); err != nil { + if err := api.starService.Delete(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to unstar dashboard", err) } @@ -147,19 +181,19 @@ func (hs *HTTPServer) UnstarDashboard(c *models.ReqContext) response.Response { // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError -func (hs *HTTPServer) UnstarDashboardByUID(c *models.ReqContext) response.Response { +func (api *API) UnstarDashboardByUID(c *contextmodel.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] if uid == "" { return response.Error(http.StatusBadRequest, "Invalid dashboard UID", nil) } - dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.OrgID, 0, uid) + dash, rsp := api.getDashboardHelper(c.Req.Context(), c.OrgID, 0, uid) if rsp != nil { return rsp } cmd := star.UnstarDashboardCommand{UserID: c.UserID, DashboardID: dash.ID} - if err := hs.starService.Delete(c.Req.Context(), &cmd); err != nil { + if err := api.starService.Delete(c.Req.Context(), &cmd); err != nil { return response.Error(http.StatusInternalServerError, "Failed to unstar dashboard", err) } diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index d70929500e7..156c541900d 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/stats" @@ -97,10 +98,10 @@ func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetS WHERE d.is_folder = ? ) AS folder_permissions,`, dialect.BooleanStr(true)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_edit", true, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_edit", false, dashboards.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "dashboards_viewers_can_admin", false, dashboards.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_edit", true, dashboards.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL(ss.db, "folders_viewers_can_admin", true, dashboards.PERMISSION_ADMIN)) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) @@ -151,7 +152,7 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { return sqlQuery } -func viewersPermissionsCounterSQL(db db.DB, statName string, isFolder bool, permission models.PermissionType) string { +func viewersPermissionsCounterSQL(db db.DB, statName string, isFolder bool, permission dashboards.PermissionType) string { dialect := db.GetDialect() return `( SELECT COUNT(*) diff --git a/pkg/services/store/entity/httpentitystore/service.go b/pkg/services/store/entity/httpentitystore/service.go index 786c8a28bfd..649206ed0b0 100644 --- a/pkg/services/store/entity/httpentitystore/service.go +++ b/pkg/services/store/entity/httpentitystore/service.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind" "github.com/grafana/grafana/pkg/util" @@ -17,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/models" ) type HTTPEntityStore interface { @@ -60,7 +60,7 @@ func (s *httpEntityStore) RegisterHTTPRoutes(route routing.RouteRegister) { // This function will extract UID+Kind from the requested path "*" in our router // This is far from ideal! but is at least consistent for these endpoints. // This will quickly be revisited as we explore how to encode UID+Kind in a "GRN" format -func (s *httpEntityStore) getGRNFromRequest(c *models.ReqContext) (*entity.GRN, map[string]string, error) { +func (s *httpEntityStore) getGRNFromRequest(c *contextmodel.ReqContext) (*entity.GRN, map[string]string, error) { params := web.Params(c.Req) // Read parameters that are encoded in the URL vals := c.Req.URL.Query() @@ -76,7 +76,7 @@ func (s *httpEntityStore) getGRNFromRequest(c *models.ReqContext) (*entity.GRN, }, params, nil } -func (s *httpEntityStore) doGetEntity(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetEntity(c *contextmodel.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -111,7 +111,7 @@ func (s *httpEntityStore) doGetEntity(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpEntityStore) doGetRawEntity(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetRawEntity(c *contextmodel.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -161,7 +161,7 @@ func (s *httpEntityStore) doGetRawEntity(c *models.ReqContext) response.Response const MAX_UPLOAD_SIZE = 5 * 1024 * 1024 // 5MB -func (s *httpEntityStore) doWriteEntity(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doWriteEntity(c *contextmodel.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -187,7 +187,7 @@ func (s *httpEntityStore) doWriteEntity(c *models.ReqContext) response.Response return response.JSON(200, rsp) } -func (s *httpEntityStore) doDeleteEntity(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doDeleteEntity(c *contextmodel.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -202,7 +202,7 @@ func (s *httpEntityStore) doDeleteEntity(c *models.ReqContext) response.Response return response.JSON(200, rsp) } -func (s *httpEntityStore) doGetHistory(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doGetHistory(c *contextmodel.ReqContext) response.Response { grn, params, err := s.getGRNFromRequest(c) if err != nil { return response.Error(400, err.Error(), err) @@ -219,7 +219,7 @@ func (s *httpEntityStore) doGetHistory(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpEntityStore) doUpload(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doUpload(c *contextmodel.ReqContext) response.Response { c.Req.Body = http.MaxBytesReader(c.Resp, c.Req.Body, MAX_UPLOAD_SIZE) if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil { msg := fmt.Sprintf("Please limit file uploaded under %s", util.ByteCountSI(MAX_UPLOAD_SIZE)) @@ -302,11 +302,11 @@ func (s *httpEntityStore) doUpload(c *models.ReqContext) response.Response { return response.JSON(200, rsp) } -func (s *httpEntityStore) doListFolder(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doListFolder(c *contextmodel.ReqContext) response.Response { return response.JSON(501, "Not implemented yet") } -func (s *httpEntityStore) doSearch(c *models.ReqContext) response.Response { +func (s *httpEntityStore) doSearch(c *contextmodel.ReqContext) response.Response { vals := c.Req.URL.Query() req := &entity.EntitySearchRequest{ diff --git a/pkg/models/entity.go b/pkg/services/store/entity/models.go similarity index 85% rename from pkg/models/entity.go rename to pkg/services/store/entity/models.go index 78ba745f4d4..84784f2808a 100644 --- a/pkg/models/entity.go +++ b/pkg/services/store/entity/models.go @@ -1,16 +1,19 @@ -package models +package entity //----------------------------------------------------------------------------------------------------- // NOTE: the object store is in heavy development, and the locations will likely continue to move //----------------------------------------------------------------------------------------------------- -import "context" +import ( + "context" +) const ( - StandardKindDashboard = "dashboard" - StandardKindPlaylist = "playlist" - StandardKindSnapshot = "snapshot" - StandardKindFolder = "folder" + StandardKindDashboard = "dashboard" + StandardKindPlaylist = "playlist" + StandardKindSnapshot = "snapshot" + StandardKindFolder = "folder" + StandardKindPreferences = "preferences" // StandardKindDataSource: not a real kind yet, but used to define references from dashboards // Types: influx, prometheus, testdata, ... @@ -20,7 +23,7 @@ const ( // Standalone panel is not an object kind yet -- library panel, or nested in dashboard StandardKindPanel = "panel" - // StandardKindSVG SVG file support + // entity.StandardKindSVG SVG file support StandardKindSVG = "svg" // StandardKindPNG PNG file support @@ -110,30 +113,23 @@ type EntitySummary struct { _ interface{} } -// This will likely get replaced with a more general error framework. -type EntityErrorInfo struct { - // TODO: Match an error code registry? - Code int64 `json:"code,omitempty"` - - // Simple error display - Message string `json:"message,omitempty"` - - // Error details - Details interface{} `json:"details,omitempty"` -} - // Reference to another object outside itself // This message is derived from the object body and can be used to search for references. // This does not represent a method to declare a reference to another object. type EntityExternalReference struct { - // datasource (instance), dashboard (instance), - Kind string `json:"kind,omitempty"` + // Category of dependency + // eg: datasource, plugin, runtime + Family string `json:"family,omitempty"` - // prometheus / heatmap, heatamp|prometheus + // datasource > prometheus|influx|... + // plugin > panel | datasource + // runtime > transformer Type string `json:"type,omitempty"` // flavor - // Unique ID for this object - UID string `json:"UID,omitempty"` + // datasource > UID + // plugin > plugin identifier + // runtime > name lookup + Identifier string `json:"ID,omitempty"` } // EntitySummaryBuilder will read an object, validate it, and return a summary, sanitized payload, or an error diff --git a/pkg/services/store/entity/sqlstash/folder_support.go b/pkg/services/store/entity/sqlstash/folder_support.go index cf5f115eef1..84e2b60c021 100644 --- a/pkg/services/store/entity/sqlstash/folder_support.go +++ b/pkg/services/store/entity/sqlstash/folder_support.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store/entity" ) @@ -41,7 +40,7 @@ func updateFolderTree(ctx context.Context, tx *session.SessionTx, tenant int64) all := []*folderInfo{} rows, err := tx.Query(ctx, "SELECT uid,folder,name,slug FROM entity WHERE kind=? AND tenant_id=? ORDER BY slug asc;", - models.StandardKindFolder, tenant) + entity.StandardKindFolder, tenant) if err != nil { return err } @@ -135,7 +134,7 @@ func setMPTTOrder(folder *folderInfo, stack []*folderInfo, idx int32) (int32, er func insertFolderInfo(ctx context.Context, tx *session.SessionTx, tenant int64, folder *folderInfo, isDetached bool) error { js, _ := json.Marshal(folder.stack) - grn := entity.GRN{TenantId: tenant, Kind: models.StandardKindFolder, UID: folder.UID} + grn := entity.GRN{TenantId: tenant, Kind: entity.StandardKindFolder, UID: folder.UID} _, err := tx.Exec(ctx, `INSERT INTO entity_folder `+ "(grn, tenant_id, uid, slug_path, tree, depth, left, right, detached) "+ diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index 92fa784a419..ee5167b6105 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/slugify" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/services/store" @@ -447,7 +446,7 @@ func (s *sqlEntityServer) AdminWrite(ctx context.Context, r *entity.AdminWriteEn origin.Source, origin.Key, origin.Time, ) } - if err == nil && models.StandardKindFolder == r.GRN.Kind { + if err == nil && entity.StandardKindFolder == r.GRN.Kind { err = updateFolderTree(ctx, tx, grn.TenantId) } if err == nil { @@ -533,10 +532,10 @@ func (s *sqlEntityServer) writeSearchInfo( return err } _, err = tx.Exec(ctx, `INSERT INTO entity_ref (`+ - "grn, parent_grn, kind, type, uid, "+ + "grn, parent_grn, family, type, id, "+ "resolved_ok, resolved_to, resolved_warning, resolved_time) "+ `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - grn, parent_grn, ref.Kind, ref.Type, ref.UID, + grn, parent_grn, ref.Family, ref.Type, ref.Identifier, resolved.OK, resolved.Key, resolved.Warning, resolved.Timestamp, ) if err != nil { @@ -663,7 +662,7 @@ func doDelete(ctx context.Context, tx *session.SessionTx, grn *entity.GRN) (bool return false, err } - if grn.Kind == models.StandardKindFolder { + if grn.Kind == entity.StandardKindFolder { err = updateFolderTree(ctx, tx, grn.TenantId) } return rows > 0, err diff --git a/pkg/services/store/entity/sqlstash/summary_handler.go b/pkg/services/store/entity/sqlstash/summary_handler.go index d78d982dd26..95c7b20b61f 100644 --- a/pkg/services/store/entity/sqlstash/summary_handler.go +++ b/pkg/services/store/entity/sqlstash/summary_handler.go @@ -3,12 +3,11 @@ package sqlstash import ( "encoding/json" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store/entity" ) type summarySupport struct { - model *models.EntitySummary + model *entity.EntitySummary name string description *string // null or empty slug *string // null or empty @@ -23,7 +22,7 @@ type summarySupport struct { isNested bool // set when this is for a nested item } -func newSummarySupport(summary *models.EntitySummary) (*summarySupport, error) { +func newSummarySupport(summary *entity.EntitySummary) (*summarySupport, error) { var err error var js []byte s := &summarySupport{ @@ -72,9 +71,9 @@ func newSummarySupport(summary *models.EntitySummary) (*summarySupport, error) { return s, err } -func (s summarySupport) toEntitySummary() (*models.EntitySummary, error) { +func (s summarySupport) toEntitySummary() (*entity.EntitySummary, error) { var err error - summary := &models.EntitySummary{ + summary := &entity.EntitySummary{ Name: s.name, } if s.description != nil { diff --git a/pkg/services/store/entity/tests/server_integration_test.go b/pkg/services/store/entity/tests/server_integration_test.go index 29d7762e1d3..0fab91445fc 100644 --- a/pkg/services/store/entity/tests/server_integration_test.go +++ b/pkg/services/store/entity/tests/server_integration_test.go @@ -8,12 +8,12 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/util" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/metadata" ) var ( @@ -133,7 +133,7 @@ func TestIntegrationEntityServer(t *testing.T) { fakeUser := store.GetUserIDString(testCtx.user) firstVersion := "1" - kind := models.StandardKindJSONObj + kind := entity.StandardKindJSONObj grn := &entity.GRN{ Kind: kind, UID: "my-test-entity", @@ -314,7 +314,7 @@ func TestIntegrationEntityServer(t *testing.T) { uid2 := "uid2" uid3 := "uid3" uid4 := "uid4" - kind2 := models.StandardKindPlaylist + kind2 := entity.StandardKindPlaylist w1, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ GRN: grn, Body: body, @@ -394,7 +394,7 @@ func TestIntegrationEntityServer(t *testing.T) { }) t.Run("should be able to filter objects based on their labels", func(t *testing.T) { - kind := models.StandardKindDashboard + kind := entity.StandardKindDashboard _, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ GRN: &entity.GRN{ Kind: kind, diff --git a/pkg/services/store/entity_events.go b/pkg/services/store/entity_events.go index 027900bd9a3..fe4ec4ff3c6 100644 --- a/pkg/services/store/entity_events.go +++ b/pkg/services/store/entity_events.go @@ -148,6 +148,10 @@ func (e *entityEventService) Run(ctx context.Context) error { type dummyEntityEventsService struct { } +func NewDummyEntityEventsService() EntityEventsService { + return dummyEntityEventsService{} +} + func (d dummyEntityEventsService) Run(ctx context.Context) error { return nil } diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go index 47219d2652c..52de0d15a95 100644 --- a/pkg/services/store/http.go +++ b/pkg/services/store/http.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -57,7 +57,7 @@ func (s *standardStorageService) RegisterHTTPRoutes(storageRoute routing.RouteRe storageRoute.Get("/config", reqGrafanaAdmin, routing.Wrap(s.getConfig)) } -func (s *standardStorageService) doWrite(c *models.ReqContext) response.Response { +func (s *standardStorageService) doWrite(c *contextmodel.ReqContext) response.Response { scope, path := getPathAndScope(c) cmd := &WriteValueRequest{} if err := web.Bind(c.Req, cmd); err != nil { @@ -71,7 +71,7 @@ func (s *standardStorageService) doWrite(c *models.ReqContext) response.Response return response.JSON(200, rsp) } -func (s *standardStorageService) doUpload(c *models.ReqContext) response.Response { +func (s *standardStorageService) doUpload(c *contextmodel.ReqContext) response.Response { type rspInfo struct { Message string `json:"message,omitempty"` Path string `json:"path,omitempty"` @@ -158,7 +158,7 @@ func getMultipartFormValue(req *http.Request, key string) string { return v[0] } -func (s *standardStorageService) read(c *models.ReqContext) response.Response { +func (s *standardStorageService) read(c *contextmodel.ReqContext) response.Response { // full path is api/storage/read/upload/example.jpg, but we only want the part after read scope, path := getPathAndScope(c) file, err := s.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path) @@ -177,7 +177,7 @@ func (s *standardStorageService) read(c *models.ReqContext) response.Response { return response.Respond(200, file.Contents) } -func (s *standardStorageService) getOptions(c *models.ReqContext) response.Response { +func (s *standardStorageService) getOptions(c *contextmodel.ReqContext) response.Response { scope, path := getPathAndScope(c) opts, err := s.getWorkflowOptions(c.Req.Context(), c.SignedInUser, scope+"/"+path) if err != nil { @@ -186,7 +186,7 @@ func (s *standardStorageService) getOptions(c *models.ReqContext) response.Respo return response.JSON(200, opts) } -func (s *standardStorageService) doDelete(c *models.ReqContext) response.Response { +func (s *standardStorageService) doDelete(c *contextmodel.ReqContext) response.Response { // full path is api/storage/delete/upload/example.jpg, but we only want the part after upload scope, path := getPathAndScope(c) @@ -201,7 +201,7 @@ func (s *standardStorageService) doDelete(c *models.ReqContext) response.Respons }) } -func (s *standardStorageService) doDeleteFolder(c *models.ReqContext) response.Response { +func (s *standardStorageService) doDeleteFolder(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(500, "error reading bytes", err) @@ -230,7 +230,7 @@ func (s *standardStorageService) doDeleteFolder(c *models.ReqContext) response.R }) } -func (s *standardStorageService) doCreateFolder(c *models.ReqContext) response.Response { +func (s *standardStorageService) doCreateFolder(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(500, "error reading bytes", err) @@ -257,7 +257,7 @@ func (s *standardStorageService) doCreateFolder(c *models.ReqContext) response.R }) } -func (s *standardStorageService) list(c *models.ReqContext) response.Response { +func (s *standardStorageService) list(c *contextmodel.ReqContext) response.Response { params := web.Params(c.Req) path := params["*"] frame, err := s.List(c.Req.Context(), c.SignedInUser, path) @@ -270,7 +270,7 @@ func (s *standardStorageService) list(c *models.ReqContext) response.Response { return response.JSONStreaming(http.StatusOK, frame) } -func (s *standardStorageService) getConfig(c *models.ReqContext) response.Response { +func (s *standardStorageService) getConfig(c *contextmodel.ReqContext) response.Response { roots := make([]RootStorageMeta, 0) orgId := c.OrgID t := s.tree diff --git a/pkg/services/store/k8saccess/client.go b/pkg/services/store/k8saccess/client.go index ecdefb90191..f8d143ecdbf 100644 --- a/pkg/services/store/k8saccess/client.go +++ b/pkg/services/store/k8saccess/client.go @@ -4,7 +4,7 @@ import ( "net/http" "net/url" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes" @@ -84,7 +84,7 @@ func defaultServerUrlFor(config *rest.Config) (*url.URL, string, error) { return rest.DefaultServerURL(host, config.APIPath, schema.GroupVersion{}, defaultTLS) } -func (s *clientWrapper) doProxy(c *models.ReqContext) { +func (s *clientWrapper) doProxy(c *contextmodel.ReqContext) { if s.baseURL == nil { c.Resp.WriteHeader(500) return diff --git a/pkg/services/store/k8saccess/dashboard_service.go b/pkg/services/store/k8saccess/dashboard_service.go new file mode 100644 index 00000000000..2aedde47aca --- /dev/null +++ b/pkg/services/store/k8saccess/dashboard_service.go @@ -0,0 +1,93 @@ +package k8saccess + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/store/entity" +) + +type k8sDashboardService struct { + orig dashboards.DashboardService + store entity.EntityStoreServer +} + +var _ dashboards.DashboardService = (*k8sDashboardService)(nil) + +func NewDashboardService(orig dashboards.DashboardService, store entity.EntityStoreServer) dashboards.DashboardService { + return &k8sDashboardService{ + orig: orig, + store: store, + } +} + +func (s *k8sDashboardService) BuildSaveDashboardCommand(ctx context.Context, dto *dashboards.SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*dashboards.SaveDashboardCommand, error) { + return s.orig.BuildSaveDashboardCommand(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) +} + +func (s *k8sDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { + return s.orig.DeleteDashboard(ctx, dashboardId, orgId) +} + +func (s *k8sDashboardService) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + return s.orig.FindDashboards(ctx, query) +} + +func (s *k8sDashboardService) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { + return s.orig.GetDashboard(ctx, query) +} + +func (s *k8sDashboardService) GetDashboardACLInfoList(ctx context.Context, query *dashboards.GetDashboardACLInfoListQuery) ([]*dashboards.DashboardACLInfoDTO, error) { + return s.orig.GetDashboardACLInfoList(ctx, query) +} + +func (s *k8sDashboardService) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { + return s.orig.GetDashboards(ctx, query) +} + +func (s *k8sDashboardService) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { + return s.orig.GetDashboardTags(ctx, query) +} + +func (s *k8sDashboardService) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { + return s.orig.GetDashboardUIDByID(ctx, query) +} + +func (s *k8sDashboardService) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { + return s.orig.HasAdminPermissionInDashboardsOrFolders(ctx, query) +} + +func (s *k8sDashboardService) HasEditPermissionInFolders(ctx context.Context, query *folder.HasEditPermissionInFoldersQuery) (bool, error) { + return s.orig.HasEditPermissionInFolders(ctx, query) +} + +func (s *k8sDashboardService) ImportDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*dashboards.Dashboard, error) { + return s.orig.ImportDashboard(ctx, dto) +} + +func (s *k8sDashboardService) MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error { + return s.orig.MakeUserAdmin(ctx, orgID, userID, dashboardID, setViewAndEditPermissions) +} + +func (s *k8sDashboardService) SaveDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO, allowUiUpdate bool) (*dashboards.Dashboard, error) { + fmt.Printf("SAVE: " + dto.Dashboard.UID) + return s.orig.SaveDashboard(ctx, dto, allowUiUpdate) +} + +func (s *k8sDashboardService) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) error { + return s.orig.SearchDashboards(ctx, query) +} + +func (s *k8sDashboardService) UpdateDashboardACL(ctx context.Context, uid int64, items []*dashboards.DashboardACL) error { + return s.orig.UpdateDashboardACL(ctx, uid, items) +} + +func (s *k8sDashboardService) DeleteACLByUser(ctx context.Context, userID int64) error { + return s.orig.DeleteACLByUser(ctx, userID) +} + +func (s *k8sDashboardService) CountDashboardsInFolder(ctx context.Context, query *dashboards.CountDashboardsInFolderQuery) (int64, error) { + return s.orig.CountDashboardsInFolder(ctx, query) +} diff --git a/pkg/services/store/k8saccess/http.go b/pkg/services/store/k8saccess/http.go index 503bc9e4f9d..4a4f64c7210 100644 --- a/pkg/services/store/k8saccess/http.go +++ b/pkg/services/store/k8saccess/http.go @@ -4,7 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) type httpHelper struct { @@ -25,7 +25,7 @@ func newHTTPHelper(access *k8sAccess, router routing.RouteRegister) *httpHelper return s } -func (s *httpHelper) showClientInfo(c *models.ReqContext) response.Response { +func (s *httpHelper) showClientInfo(c *contextmodel.ReqContext) response.Response { if s.access.sys != nil { info := s.access.sys.getInfo() if s.access.sys.err != nil { @@ -38,7 +38,7 @@ func (s *httpHelper) showClientInfo(c *models.ReqContext) response.Response { }) } -func (s *httpHelper) doProxy(c *models.ReqContext) { +func (s *httpHelper) doProxy(c *contextmodel.ReqContext) { // TODO... this does not yet do a real proxy if s.access.sys != nil { if s.access.sys.err == nil { diff --git a/pkg/services/store/kind/dashboard/reference.go b/pkg/services/store/kind/dashboard/reference.go index 25b78c35f2f..e71369a6bf2 100644 --- a/pkg/services/store/kind/dashboard/reference.go +++ b/pkg/services/store/kind/dashboard/reference.go @@ -4,48 +4,48 @@ import ( "fmt" "sort" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" ) // A reference accumulator can combine type ReferenceAccumulator interface { // Add references as we find them - Add(kind string, subtype string, uid string) + Add(family string, ttype string, id string) // Returns the set of distinct references in a sorted order - Get() []*models.EntityExternalReference + Get() []*entity.EntityExternalReference } func NewReferenceAccumulator() ReferenceAccumulator { return &referenceAccumulator{ - refs: make(map[string]*models.EntityExternalReference), + refs: make(map[string]*entity.EntityExternalReference), } } type referenceAccumulator struct { - refs map[string]*models.EntityExternalReference + refs map[string]*entity.EntityExternalReference } -func (x *referenceAccumulator) Add(kind string, sub string, uid string) { - key := fmt.Sprintf("%s/%s/%s", kind, sub, uid) +func (x *referenceAccumulator) Add(family string, ttype string, id string) { + key := fmt.Sprintf("%s/%s/%s", family, ttype, id) _, ok := x.refs[key] if !ok { - x.refs[key] = &models.EntityExternalReference{ - Kind: kind, - Type: sub, - UID: uid, + x.refs[key] = &entity.EntityExternalReference{ + Family: family, + Type: ttype, + Identifier: id, } } } -func (x *referenceAccumulator) Get() []*models.EntityExternalReference { +func (x *referenceAccumulator) Get() []*entity.EntityExternalReference { keys := make([]string, 0, len(x.refs)) for k := range x.refs { keys = append(keys, k) } sort.Strings(keys) - refs := make([]*models.EntityExternalReference, len(keys)) + refs := make([]*entity.EntityExternalReference, len(keys)) for i, key := range keys { refs[i] = x.refs[key] } diff --git a/pkg/services/store/kind/dashboard/summary.go b/pkg/services/store/kind/dashboard/summary.go index 7038db0c306..51a1193a8b7 100644 --- a/pkg/services/store/kind/dashboard/summary.go +++ b/pkg/services/store/kind/dashboard/summary.go @@ -6,29 +6,29 @@ import ( "encoding/json" "strconv" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindDashboard, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindDashboard, Name: "Dashboard", Description: "Define a grafana dashboard layout", } } // This summary does not resolve old name as UID -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { builder := NewStaticDashboardSummaryBuilder(&directLookup{}, true) - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { return builder(ctx, uid, body) } } // This implementation moves datasources referenced by internal ID or name to UID -func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { var parsed map[string]interface{} if sanitize { @@ -42,14 +42,14 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) mo // slug? (derived from title) } - summary := &models.EntitySummary{ + summary := &entity.EntitySummary{ Labels: make(map[string]string), Fields: make(map[string]interface{}), } stream := bytes.NewBuffer(body) dash, err := readDashboard(stream, lookup) if err != nil { - summary.Error = &models.EntityErrorInfo{ + summary.Error = &entity.EntityErrorInfo{ Message: err.Error(), } return summary, body, err @@ -68,7 +68,7 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) mo for _, panel := range dash.Panels { panelRefs := NewReferenceAccumulator() - p := &models.EntitySummary{ + p := &entity.EntitySummary{ UID: uid + "#" + strconv.FormatInt(panel.ID, 10), Kind: "panel", } @@ -78,19 +78,19 @@ func NewStaticDashboardSummaryBuilder(lookup DatasourceLookup, sanitize bool) mo p.Fields["type"] = panel.Type if panel.Type != "row" { - panelRefs.Add(models.ExternalEntityReferencePlugin, string(plugins.Panel), panel.Type) - dashboardRefs.Add(models.ExternalEntityReferencePlugin, string(plugins.Panel), panel.Type) + panelRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.Panel), panel.Type) + dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.Panel), panel.Type) } for _, v := range panel.Datasource { - dashboardRefs.Add(models.StandardKindDataSource, v.Type, v.UID) - panelRefs.Add(models.StandardKindDataSource, v.Type, v.UID) + dashboardRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) + panelRefs.Add(entity.StandardKindDataSource, v.Type, v.UID) if v.Type != "" { - dashboardRefs.Add(models.ExternalEntityReferencePlugin, string(plugins.DataSource), v.Type) + dashboardRefs.Add(entity.ExternalEntityReferencePlugin, string(plugins.DataSource), v.Type) } } for _, v := range panel.Transformer { - panelRefs.Add(models.ExternalEntityReferenceRuntime, models.ExternalEntityReferenceRuntime_Transformer, v) - dashboardRefs.Add(models.ExternalEntityReferenceRuntime, models.ExternalEntityReferenceRuntime_Transformer, v) + panelRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) + dashboardRefs.Add(entity.ExternalEntityReferenceRuntime, entity.ExternalEntityReferenceRuntime_Transformer, v) } p.References = panelRefs.Get() summary.Nested = append(summary.Nested, p) diff --git a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-gradient-area-fills.json b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-gradient-area-fills.json index 1af4e059861..9eb1a26b1a5 100644 --- a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-gradient-area-fills.json +++ b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-gradient-area-fills.json @@ -18,12 +18,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -36,12 +36,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -54,12 +54,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -72,24 +72,24 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } ], "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-shared-tooltips.json b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-shared-tooltips.json index 10c63c88fcb..9406933bac2 100644 --- a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-shared-tooltips.json +++ b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-shared-tooltips.json @@ -18,12 +18,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "timeseries" + "ID": "timeseries" } ] }, @@ -36,22 +36,22 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "xychart" + "ID": "xychart" }, { - "kind": "runtime", + "family": "runtime", "type": "transformer", - "UID": "organize" + "ID": "organize" }, { - "kind": "runtime", + "family": "runtime", "type": "transformer", - "UID": "seriesToColumns" + "ID": "seriesToColumns" } ] }, @@ -64,12 +64,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "debug" + "ID": "debug" } ] }, @@ -82,12 +82,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "timeseries" + "ID": "timeseries" } ] }, @@ -100,12 +100,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "timeseries" + "ID": "timeseries" } ] }, @@ -118,12 +118,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "timeseries" + "ID": "timeseries" } ] }, @@ -136,12 +136,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -154,49 +154,49 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } ], "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "debug" + "ID": "debug" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "timeseries" + "ID": "timeseries" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "xychart" + "ID": "xychart" }, { - "kind": "runtime", + "family": "runtime", "type": "transformer", - "UID": "organize" + "ID": "organize" }, { - "kind": "runtime", + "family": "runtime", "type": "transformer", - "UID": "seriesToColumns" + "ID": "seriesToColumns" } ] } \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-time-regions.json b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-time-regions.json index 46c2b25e41b..d91dfe46b88 100644 --- a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-time-regions.json +++ b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph-time-regions.json @@ -18,13 +18,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -37,13 +37,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -56,13 +56,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -75,13 +75,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -94,26 +94,26 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } ], "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_tests.json b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_tests.json index b207ad9d31d..61630217a55 100644 --- a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_tests.json +++ b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_tests.json @@ -18,13 +18,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -37,13 +37,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -56,13 +56,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -75,13 +75,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -93,12 +93,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -111,13 +111,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -129,12 +129,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -147,13 +147,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -166,13 +166,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -184,12 +184,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -202,13 +202,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -220,12 +220,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -238,13 +238,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -256,12 +256,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -274,13 +274,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -292,12 +292,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] }, @@ -310,13 +310,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -329,13 +329,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -348,13 +348,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -367,13 +367,13 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -386,34 +386,34 @@ }, "references": [ { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } ], "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "ds", - "UID": "gdev-testdata" + "family": "ds", + "ID": "gdev-testdata" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "text" + "ID": "text" } ] } \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_y_axis.json b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_y_axis.json index cd8f78665d2..8ee9d100953 100644 --- a/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_y_axis.json +++ b/pkg/services/store/kind/dashboard/testdata/gdev-walk-graph_y_axis.json @@ -17,12 +17,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -35,12 +35,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -53,12 +53,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -71,12 +71,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -89,12 +89,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -107,12 +107,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -125,12 +125,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -143,12 +143,12 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] }, @@ -161,24 +161,24 @@ }, "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } ], "references": [ { - "kind": "ds" + "family": "ds" }, { - "kind": "plugin", + "family": "plugin", "type": "panel", - "UID": "graph" + "ID": "graph" } ] } \ No newline at end of file diff --git a/pkg/services/store/kind/dataframe/summary.go b/pkg/services/store/kind/dataframe/summary.go index 88c5df709b9..cc1abf69656 100644 --- a/pkg/services/store/kind/dataframe/summary.go +++ b/pkg/services/store/kind/dataframe/summary.go @@ -5,20 +5,21 @@ import ( "encoding/json" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindDataFrame, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindDataFrame, Name: "Data frame", Description: "Data frame", } } -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { df := &data.Frame{} err := json.Unmarshal(body, df) if err != nil { @@ -33,8 +34,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { if err != nil { return nil, nil, err } - summary := &models.EntitySummary{ - Kind: models.StandardKindDataFrame, + summary := &entity.EntitySummary{ + Kind: entity.StandardKindDataFrame, Name: df.Name, UID: uid, Fields: map[string]interface{}{ diff --git a/pkg/services/store/kind/dummy/summary.go b/pkg/services/store/kind/dummy/summary.go index cdf88f97530..06f60aa8854 100644 --- a/pkg/services/store/kind/dummy/summary.go +++ b/pkg/services/store/kind/dummy/summary.go @@ -5,11 +5,11 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo(kind string) models.EntityKindInfo { - return models.EntityKindInfo{ +func GetEntityKindInfo(kind string) entity.EntityKindInfo { + return entity.EntityKindInfo{ ID: kind, Name: kind, Description: "Dummy kind used for testing.", @@ -17,9 +17,9 @@ func GetEntityKindInfo(kind string) models.EntityKindInfo { } } -func GetEntitySummaryBuilder(kind string) models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { - summary := &models.EntitySummary{ +func GetEntitySummaryBuilder(kind string) entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { + summary := &entity.EntitySummary{ Name: fmt.Sprintf("Dummy: %s", kind), Kind: kind, Description: fmt.Sprintf("Wrote at %s", time.Now().Local().String()), @@ -35,19 +35,19 @@ func GetEntitySummaryBuilder(kind string) models.EntitySummaryBuilder { }, Error: nil, // ignore for now Nested: nil, // ignore for now - References: []*models.EntityExternalReference{ + References: []*entity.EntityExternalReference{ { - Kind: "ds", - Type: "influx", - UID: "xyz", + Family: "ds", + Type: "influx", + Identifier: "xyz", }, { - Kind: "panel", - Type: "heatmap", + Family: entity.StandardKindPanel, + Type: "heatmap", }, { - Kind: "panel", - Type: "timeseries", + Family: entity.StandardKindPanel, + Type: "timeseries", }, }, } diff --git a/pkg/services/store/kind/folder/summary.go b/pkg/services/store/kind/folder/summary.go index 0e24a95fa38..a328e139ace 100644 --- a/pkg/services/store/kind/folder/summary.go +++ b/pkg/services/store/kind/folder/summary.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" ) type Model struct { @@ -13,15 +13,15 @@ type Model struct { Description string `json:"description,omitempty"` } -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindFolder, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindFolder, Name: "Folder", } } -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { obj := &Model{} err := json.Unmarshal(body, obj) if err != nil { @@ -32,8 +32,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { obj.Name = store.GuessNameFromUID(uid) } - summary := &models.EntitySummary{ - Kind: models.StandardKindFolder, + summary := &entity.EntitySummary{ + Kind: entity.StandardKindFolder, Name: obj.Name, Description: obj.Description, UID: uid, diff --git a/pkg/services/store/kind/geojson/summary.go b/pkg/services/store/kind/geojson/summary.go index 55c7c115929..2a4b410814d 100644 --- a/pkg/services/store/kind/geojson/summary.go +++ b/pkg/services/store/kind/geojson/summary.go @@ -5,13 +5,13 @@ import ( "encoding/json" "fmt" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindGeoJSON, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindGeoJSON, Name: "GeoJSON", Description: "JSON formatted spatial data", FileExtension: ".geojson", @@ -20,8 +20,8 @@ func GetEntityKindInfo() models.EntityKindInfo { } // Very basic geojson validator -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { var geojson map[string]interface{} err := json.Unmarshal(body, &geojson) if err != nil { @@ -38,8 +38,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { return nil, nil, err } - summary := &models.EntitySummary{ - Kind: models.StandardKindGeoJSON, + summary := &entity.EntitySummary{ + Kind: entity.StandardKindGeoJSON, Name: store.GuessNameFromUID(uid), UID: uid, Fields: map[string]interface{}{ diff --git a/pkg/services/store/kind/jsonobj/summary.go b/pkg/services/store/kind/jsonobj/summary.go index 7bbe5051778..30d13e949b3 100644 --- a/pkg/services/store/kind/jsonobj/summary.go +++ b/pkg/services/store/kind/jsonobj/summary.go @@ -4,20 +4,20 @@ import ( "context" "encoding/json" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindJSONObj, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindJSONObj, Name: "JSON Object", Description: "JSON Object", } } -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { v := make(map[string]interface{}) err := json.Unmarshal(body, &v) if err != nil { @@ -28,8 +28,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { if err != nil { return nil, nil, err } - return &models.EntitySummary{ - Kind: models.StandardKindJSONObj, + return &entity.EntitySummary{ + Kind: entity.StandardKindJSONObj, Name: store.GuessNameFromUID(uid), UID: uid, }, out, err diff --git a/pkg/services/store/kind/playlist/summary.go b/pkg/services/store/kind/playlist/summary.go index 52c03ab1eb4..959f4f2f39b 100644 --- a/pkg/services/store/kind/playlist/summary.go +++ b/pkg/services/store/kind/playlist/summary.go @@ -6,22 +6,22 @@ import ( "fmt" "github.com/grafana/grafana/pkg/kinds/playlist" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindPlaylist, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindPlaylist, Name: "Playlist", Description: "Cycle though a collection of dashboards automatically", } } -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { return summaryBuilder } -func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func summaryBuilder(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { obj := &playlist.Playlist{} err := json.Unmarshal(body, obj) if err != nil { @@ -35,7 +35,7 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Entit } obj.Uid = uid // make sure they are consistent - summary := &models.EntitySummary{ + summary := &entity.EntitySummary{ UID: uid, Name: obj.Name, Description: fmt.Sprintf("%d items, refreshed every %s", len(*obj.Items), obj.Interval), @@ -44,9 +44,9 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Entit for _, item := range *obj.Items { switch item.Type { case playlist.ItemTypeDashboardByUid: - summary.References = append(summary.References, &models.EntityExternalReference{ - Kind: "dashboard", - UID: item.Value, + summary.References = append(summary.References, &entity.EntityExternalReference{ + Family: entity.StandardKindDashboard, + Identifier: item.Value, }) case playlist.ItemTypeDashboardByTag: @@ -57,7 +57,7 @@ func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.Entit case playlist.ItemTypeDashboardById: // obviously insufficient long term... but good to have an example :) - summary.Error = &models.EntityErrorInfo{ + summary.Error = &entity.EntityErrorInfo{ Message: "Playlist uses deprecated internal id system", } } diff --git a/pkg/services/store/kind/png/summary.go b/pkg/services/store/kind/png/summary.go index cfa325b5096..c7d7dadbc6d 100644 --- a/pkg/services/store/kind/png/summary.go +++ b/pkg/services/store/kind/png/summary.go @@ -5,13 +5,13 @@ import ( "context" "image/png" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindPNG, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindPNG, Name: "PNG", Description: "PNG Image file", IsRaw: true, @@ -21,16 +21,16 @@ func GetEntityKindInfo() models.EntityKindInfo { } // SVG sanitizer based on the rendering service -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { img, err := png.Decode(bytes.NewReader(body)) if err != nil { return nil, nil, err } size := img.Bounds().Size() - summary := &models.EntitySummary{ - Kind: models.StandardKindSVG, + summary := &entity.EntitySummary{ + Kind: entity.StandardKindSVG, Name: store.GuessNameFromUID(uid), UID: uid, Fields: map[string]interface{}{ diff --git a/pkg/services/store/kind/preferences/summary.go b/pkg/services/store/kind/preferences/summary.go new file mode 100644 index 00000000000..c3b557f788e --- /dev/null +++ b/pkg/services/store/kind/preferences/summary.go @@ -0,0 +1,54 @@ +package preferences + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/kinds/preferences" + "github.com/grafana/grafana/pkg/services/store/entity" +) + +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindPreferences, + Name: "Preferences", + } +} + +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { + if uid != "default" { + parts := strings.Split(uid, "-") + if len(parts) != 2 { + return nil, nil, fmt.Errorf("expecting UID: default, user-{#}, or team-{#}") + } + if !(parts[0] == "team" || parts[0] == "user") { + return nil, nil, fmt.Errorf("expecting UID: default, user-{#}, or team-{#}") + } + } + + obj := &preferences.Preferences{} + err := json.Unmarshal(body, obj) + if err != nil { + return nil, nil, err // unable to read object + } + + summary := &entity.EntitySummary{ + Kind: entity.StandardKindPreferences, + Name: uid, // team-${id} | user-${id} + UID: uid, + } + + if obj.HomeDashboardUID != nil && *obj.HomeDashboardUID != "" { + summary.References = append(summary.References, &entity.EntityExternalReference{ + Family: entity.StandardKindDashboard, + Identifier: *obj.HomeDashboardUID, + }) + } + + out, err := json.MarshalIndent(obj, "", " ") + return summary, out, err + } +} diff --git a/pkg/services/store/kind/registry.go b/pkg/services/store/kind/registry.go index 7c76f106e3e..f720c68d452 100644 --- a/pkg/services/store/kind/registry.go +++ b/pkg/services/store/kind/registry.go @@ -5,8 +5,8 @@ import ( "sort" "sync" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/services/store/kind/dataframe" "github.com/grafana/grafana/pkg/services/store/kind/folder" @@ -14,53 +14,58 @@ import ( "github.com/grafana/grafana/pkg/services/store/kind/jsonobj" "github.com/grafana/grafana/pkg/services/store/kind/playlist" "github.com/grafana/grafana/pkg/services/store/kind/png" + "github.com/grafana/grafana/pkg/services/store/kind/preferences" "github.com/grafana/grafana/pkg/services/store/kind/snapshot" "github.com/grafana/grafana/pkg/services/store/kind/svg" "github.com/grafana/grafana/pkg/setting" ) type KindRegistry interface { - Register(info models.EntityKindInfo, builder models.EntitySummaryBuilder) error - GetSummaryBuilder(kind string) models.EntitySummaryBuilder - GetInfo(kind string) (models.EntityKindInfo, error) - GetFromExtension(suffix string) (models.EntityKindInfo, error) - GetKinds() []models.EntityKindInfo + Register(info entity.EntityKindInfo, builder entity.EntitySummaryBuilder) error + GetSummaryBuilder(kind string) entity.EntitySummaryBuilder + GetInfo(kind string) (entity.EntityKindInfo, error) + GetFromExtension(suffix string) (entity.EntityKindInfo, error) + GetKinds() []entity.EntityKindInfo } func NewKindRegistry() KindRegistry { kinds := make(map[string]*kindValues) - kinds[models.StandardKindPlaylist] = &kindValues{ + kinds[entity.StandardKindPlaylist] = &kindValues{ info: playlist.GetEntityKindInfo(), builder: playlist.GetEntitySummaryBuilder(), } - kinds[models.StandardKindDashboard] = &kindValues{ + kinds[entity.StandardKindDashboard] = &kindValues{ info: dashboard.GetEntityKindInfo(), builder: dashboard.GetEntitySummaryBuilder(), } - kinds[models.StandardKindSnapshot] = &kindValues{ + kinds[entity.StandardKindSnapshot] = &kindValues{ info: snapshot.GetEntityKindInfo(), builder: snapshot.GetEntitySummaryBuilder(), } - kinds[models.StandardKindFolder] = &kindValues{ + kinds[entity.StandardKindFolder] = &kindValues{ info: folder.GetEntityKindInfo(), builder: folder.GetEntitySummaryBuilder(), } - kinds[models.StandardKindPNG] = &kindValues{ + kinds[entity.StandardKindPNG] = &kindValues{ info: png.GetEntityKindInfo(), builder: png.GetEntitySummaryBuilder(), } - kinds[models.StandardKindGeoJSON] = &kindValues{ + kinds[entity.StandardKindGeoJSON] = &kindValues{ info: geojson.GetEntityKindInfo(), builder: geojson.GetEntitySummaryBuilder(), } - kinds[models.StandardKindDataFrame] = &kindValues{ + kinds[entity.StandardKindDataFrame] = &kindValues{ info: dataframe.GetEntityKindInfo(), builder: dataframe.GetEntitySummaryBuilder(), } - kinds[models.StandardKindJSONObj] = &kindValues{ + kinds[entity.StandardKindJSONObj] = &kindValues{ info: jsonobj.GetEntityKindInfo(), builder: jsonobj.GetEntitySummaryBuilder(), } + kinds[entity.StandardKindPreferences] = &kindValues{ + info: preferences.GetEntityKindInfo(), + builder: preferences.GetEntitySummaryBuilder(), + } // create a registry reg := ®istry{ @@ -86,20 +91,20 @@ func ProvideService(cfg *setting.Cfg, renderer rendering.Service) KindRegistry { } type kindValues struct { - info models.EntityKindInfo - builder models.EntitySummaryBuilder + info entity.EntityKindInfo + builder entity.EntitySummaryBuilder } type registry struct { mutex sync.RWMutex kinds map[string]*kindValues - info []models.EntityKindInfo - suffix map[string]models.EntityKindInfo + info []entity.EntityKindInfo + suffix map[string]entity.EntityKindInfo } func (r *registry) updateInfoArray() { - suffix := make(map[string]models.EntityKindInfo) - info := make([]models.EntityKindInfo, 0, len(r.kinds)) + suffix := make(map[string]entity.EntityKindInfo) + info := make([]entity.EntityKindInfo, 0, len(r.kinds)) for _, v := range r.kinds { info = append(info, v.info) if v.info.FileExtension != "" { @@ -113,7 +118,7 @@ func (r *registry) updateInfoArray() { r.suffix = suffix } -func (r *registry) Register(info models.EntityKindInfo, builder models.EntitySummaryBuilder) error { +func (r *registry) Register(info entity.EntityKindInfo, builder entity.EntitySummaryBuilder) error { if info.ID == "" || builder == nil { return fmt.Errorf("invalid kind") } @@ -134,7 +139,7 @@ func (r *registry) Register(info models.EntityKindInfo, builder models.EntitySum } // GetSummaryBuilder returns a builder or nil if not found -func (r *registry) GetSummaryBuilder(kind string) models.EntitySummaryBuilder { +func (r *registry) GetSummaryBuilder(kind string) entity.EntitySummaryBuilder { r.mutex.RLock() defer r.mutex.RUnlock() @@ -146,7 +151,7 @@ func (r *registry) GetSummaryBuilder(kind string) models.EntitySummaryBuilder { } // GetInfo returns the registered info -func (r *registry) GetInfo(kind string) (models.EntityKindInfo, error) { +func (r *registry) GetInfo(kind string) (entity.EntityKindInfo, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -154,11 +159,11 @@ func (r *registry) GetInfo(kind string) (models.EntityKindInfo, error) { if ok { return v.info, nil } - return models.EntityKindInfo{}, fmt.Errorf("not found") + return entity.EntityKindInfo{}, fmt.Errorf("not found") } // GetInfo returns the registered info -func (r *registry) GetFromExtension(suffix string) (models.EntityKindInfo, error) { +func (r *registry) GetFromExtension(suffix string) (entity.EntityKindInfo, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -166,11 +171,11 @@ func (r *registry) GetFromExtension(suffix string) (models.EntityKindInfo, error if ok { return v, nil } - return models.EntityKindInfo{}, fmt.Errorf("not found") + return entity.EntityKindInfo{}, fmt.Errorf("not found") } // GetSummaryBuilder returns a builder or nil if not found -func (r *registry) GetKinds() []models.EntityKindInfo { +func (r *registry) GetKinds() []entity.EntityKindInfo { r.mutex.RLock() defer r.mutex.RUnlock() diff --git a/pkg/services/store/kind/registry_test.go b/pkg/services/store/kind/registry_test.go index 3b92361cfff..9443edc1263 100644 --- a/pkg/services/store/kind/registry_test.go +++ b/pkg/services/store/kind/registry_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind/dummy" ) @@ -26,12 +26,13 @@ func TestKindRegistry(t *testing.T) { "jsonobj", "playlist", "png", + "preferences", "snapshot", "test", }, ids) // Check playlist exists - info, err := registry.GetInfo(models.StandardKindPlaylist) + info, err := registry.GetInfo(entity.StandardKindPlaylist) require.NoError(t, err) require.Equal(t, "Playlist", info.Name) require.False(t, info.IsRaw) diff --git a/pkg/services/store/kind/snapshot/summary.go b/pkg/services/store/kind/snapshot/summary.go index 7994851fe49..d19ed188a16 100644 --- a/pkg/services/store/kind/snapshot/summary.go +++ b/pkg/services/store/kind/snapshot/summary.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store/entity" ) // A snapshot is a dashboard with no external queries and a few additional properties @@ -19,15 +19,15 @@ type Model struct { Snapshot json.RawMessage `json:"snapshot,omitempty"` } -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindSnapshot, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindSnapshot, Name: "Snapshot", } } -func GetEntitySummaryBuilder() models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder() entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { obj := &Model{} err := json.Unmarshal(body, obj) if err != nil { @@ -41,8 +41,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { return nil, nil, fmt.Errorf("expected delete key") } - summary := &models.EntitySummary{ - Kind: models.StandardKindFolder, + summary := &entity.EntitySummary{ + Kind: entity.StandardKindFolder, Name: obj.Name, Description: obj.Description, UID: uid, @@ -51,8 +51,8 @@ func GetEntitySummaryBuilder() models.EntitySummaryBuilder { "externalURL": obj.ExternalURL, "expires": obj.Expires, }, - References: []*models.EntityExternalReference{ - {Kind: models.StandardKindDashboard, UID: obj.DashboardUID}, + References: []*entity.EntityExternalReference{ + {Family: entity.StandardKindDashboard, Identifier: obj.DashboardUID}, }, } diff --git a/pkg/services/store/kind/svg/summary.go b/pkg/services/store/kind/svg/summary.go index a13c51ca2ad..474a81fb5e2 100644 --- a/pkg/services/store/kind/svg/summary.go +++ b/pkg/services/store/kind/svg/summary.go @@ -5,13 +5,13 @@ import ( "fmt" "strings" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/store/entity" ) -func GetEntityKindInfo() models.EntityKindInfo { - return models.EntityKindInfo{ - ID: models.StandardKindSVG, +func GetEntityKindInfo() entity.EntityKindInfo { + return entity.EntityKindInfo{ + ID: entity.StandardKindSVG, Name: "SVG", Description: "Scalable Vector Graphics", IsRaw: true, @@ -21,8 +21,8 @@ func GetEntityKindInfo() models.EntityKindInfo { } // SVG sanitizer based on the rendering service -func GetEntitySummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering.Service) models.EntitySummaryBuilder { - return func(ctx context.Context, uid string, body []byte) (*models.EntitySummary, []byte, error) { +func GetEntitySummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering.Service) entity.EntitySummaryBuilder { + return func(ctx context.Context, uid string, body []byte) (*entity.EntitySummary, []byte, error) { if !IsSVG(body) { return nil, nil, fmt.Errorf("invalid svg") } @@ -45,8 +45,8 @@ func GetEntitySummaryBuilder(allowUnsanitizedSvgUpload bool, renderer rendering. sanitized = body } - return &models.EntitySummary{ - Kind: models.StandardKindSVG, + return &entity.EntitySummary{ + Kind: entity.StandardKindSVG, Name: guessNameFromUID(uid), UID: uid, }, sanitized, nil diff --git a/pkg/services/store/resolver/service.go b/pkg/services/store/resolver/service.go index 4160edbea5b..01c01ba732b 100644 --- a/pkg/services/store/resolver/service.go +++ b/pkg/services/store/resolver/service.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/store/entity" ) const ( @@ -28,7 +28,7 @@ type ResolutionInfo struct { } type EntityReferenceResolver interface { - Resolve(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) + Resolve(ctx context.Context, ref *entity.EntityExternalReference) (ResolutionInfo, error) } func ProvideEntityReferenceResolver(ds datasources.DataSourceService, pluginStore plugins.Store) EntityReferenceResolver { @@ -46,19 +46,19 @@ type standardReferenceResolver struct { ds dsCache } -func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { +func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *entity.EntityExternalReference) (ResolutionInfo, error) { if ref == nil { return ResolutionInfo{OK: false, Timestamp: getNow()}, fmt.Errorf("ref is nil") } - switch ref.Kind { - case models.StandardKindDataSource: + switch ref.Family { + case entity.StandardKindDataSource: return r.resolveDatasource(ctx, ref) - case models.ExternalEntityReferencePlugin: + case entity.ExternalEntityReferencePlugin: return r.resolvePlugin(ctx, ref) - // case models.ExternalEntityReferenceRuntime: + // case entity.ExternalEntityReferenceRuntime: // return ResolutionInfo{ // OK: false, // Timestamp: getNow(), @@ -73,8 +73,8 @@ func (r *standardReferenceResolver) Resolve(ctx context.Context, ref *models.Ent }, nil } -func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { - ds, err := r.ds.getDS(ctx, ref.UID) +func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref *entity.EntityExternalReference) (ResolutionInfo, error) { + ds, err := r.ds.getDS(ctx, ref.Identifier) if err != nil || ds == nil || ds.UID == "" { return ResolutionInfo{ OK: false, @@ -99,8 +99,8 @@ func (r *standardReferenceResolver) resolveDatasource(ctx context.Context, ref * return res, nil } -func (r *standardReferenceResolver) resolvePlugin(ctx context.Context, ref *models.EntityExternalReference) (ResolutionInfo, error) { - p, ok := r.pluginStore.Plugin(ctx, ref.UID) +func (r *standardReferenceResolver) resolvePlugin(ctx context.Context, ref *entity.EntityExternalReference) (ResolutionInfo, error) { + p, ok := r.pluginStore.Plugin(ctx, ref.Identifier) if !ok { return ResolutionInfo{ OK: false, diff --git a/pkg/services/store/resolver/service_test.go b/pkg/services/store/resolver/service_test.go index b420b27a508..f0c05043065 100644 --- a/pkg/services/store/resolver/service_test.go +++ b/pkg/services/store/resolver/service_test.go @@ -4,13 +4,14 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/require" ) func TestResolver(t *testing.T) { @@ -49,34 +50,34 @@ func TestResolver(t *testing.T) { scenarios := []struct { name string - given *models.EntityExternalReference + given *entity.EntityExternalReference expect ResolutionInfo err string ctx context.Context }{ { name: "Missing datasource without type", - given: &models.EntityExternalReference{ - Kind: models.StandardKindDataSource, - UID: "xyz", + given: &entity.EntityExternalReference{ + Family: entity.StandardKindDataSource, + Identifier: "xyz", }, expect: ResolutionInfo{OK: false}, ctx: ctxOrg1, }, { name: "OK datasource", - given: &models.EntityExternalReference{ - Kind: models.StandardKindDataSource, - Type: "influx", - UID: "influx-uid", + given: &entity.EntityExternalReference{ + Family: entity.StandardKindDataSource, + Type: "influx", + Identifier: "influx-uid", }, expect: ResolutionInfo{OK: true, Key: "influx-uid"}, ctx: ctxOrg1, }, { name: "Get the default datasource", - given: &models.EntityExternalReference{ - Kind: models.StandardKindDataSource, + given: &entity.EntityExternalReference{ + Family: entity.StandardKindDataSource, }, expect: ResolutionInfo{ OK: true, @@ -87,9 +88,9 @@ func TestResolver(t *testing.T) { }, { name: "Get the default datasource (with type)", - given: &models.EntityExternalReference{ - Kind: models.StandardKindDataSource, - Type: "influx", + given: &entity.EntityExternalReference{ + Family: entity.StandardKindDataSource, + Type: "influx", }, expect: ResolutionInfo{ OK: true, @@ -99,9 +100,9 @@ func TestResolver(t *testing.T) { }, { name: "Lookup by name", - given: &models.EntityExternalReference{ - Kind: models.StandardKindDataSource, - UID: "Influx2", + given: &entity.EntityExternalReference{ + Family: entity.StandardKindDataSource, + Identifier: "Influx2", }, expect: ResolutionInfo{ OK: true, diff --git a/pkg/services/store/utils.go b/pkg/services/store/utils.go index fe275b51291..6537af8ab7b 100644 --- a/pkg/services/store/utils.go +++ b/pkg/services/store/utils.go @@ -3,7 +3,7 @@ package store import ( "strings" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/web" ) @@ -32,7 +32,7 @@ func splitFirstSegment(path string) (string, string) { return path, "" } -func getPathAndScope(c *models.ReqContext) (string, string) { +func getPathAndScope(c *contextmodel.ReqContext) (string, string) { params := web.Params(c.Req) path := params["*"] if path == "" { diff --git a/pkg/services/supportbundles/interface.go b/pkg/services/supportbundles/interface.go index 66ed4a1e867..e876412087b 100644 --- a/pkg/services/supportbundles/interface.go +++ b/pkg/services/supportbundles/interface.go @@ -32,12 +32,20 @@ type Bundle struct { type CollectorFunc func(context.Context) (*SupportItem, error) type Collector struct { - UID string `json:"uid"` - DisplayName string `json:"displayName"` - Description string `json:"description"` - IncludedByDefault bool `json:"includedByDefault"` - Default bool `json:"default"` - Fn CollectorFunc `json:"-"` + // UID is a unique identifier for the collector. + UID string `json:"uid"` + // DisplayName is the name of the collector. User facing. + DisplayName string `json:"displayName"` + // Description is a description of the collector. User facing. + Description string `json:"description"` + // IncludedByDefault determines if the collector is included by default. + // User cannot override this. + IncludedByDefault bool `json:"includedByDefault"` + // Default determines if the collector is included by default. + // User can override this. + Default bool `json:"default"` + // Fn is the function that collects the support item. + Fn CollectorFunc `json:"-"` } type Service interface { diff --git a/pkg/services/supportbundles/supportbundlesimpl/api.go b/pkg/services/supportbundles/supportbundlesimpl/api.go index 373789d1a31..74551db8419 100644 --- a/pkg/services/supportbundles/supportbundlesimpl/api.go +++ b/pkg/services/supportbundles/supportbundlesimpl/api.go @@ -10,9 +10,9 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/web" ) @@ -49,7 +49,7 @@ func (s *Service) registerAPIEndpoints(httpServer *grafanaApi.HTTPServer, routeR }) } -func (s *Service) handleList(ctx *models.ReqContext) response.Response { +func (s *Service) handleList(ctx *contextmodel.ReqContext) response.Response { bundles, err := s.list(ctx.Req.Context()) if err != nil { return response.Error(http.StatusInternalServerError, "failed to list bundles", err) @@ -63,7 +63,7 @@ func (s *Service) handleList(ctx *models.ReqContext) response.Response { return response.JSON(http.StatusOK, data) } -func (s *Service) handleCreate(ctx *models.ReqContext) response.Response { +func (s *Service) handleCreate(ctx *contextmodel.ReqContext) response.Response { type command struct { Collectors []string `json:"collectors"` } @@ -86,7 +86,7 @@ func (s *Service) handleCreate(ctx *models.ReqContext) response.Response { return response.JSON(http.StatusCreated, data) } -func (s *Service) handleDownload(ctx *models.ReqContext) response.Response { +func (s *Service) handleDownload(ctx *contextmodel.ReqContext) response.Response { uid := web.Params(ctx.Req)[":uid"] bundle, err := s.get(ctx.Req.Context(), uid) if err != nil { @@ -102,7 +102,7 @@ func (s *Service) handleDownload(ctx *models.ReqContext) response.Response { return response.CreateNormalResponse(ctx.Resp.Header(), bundle.TarBytes, http.StatusOK) } -func (s *Service) handleRemove(ctx *models.ReqContext) response.Response { +func (s *Service) handleRemove(ctx *contextmodel.ReqContext) response.Response { uid := web.Params(ctx.Req)[":uid"] err := s.remove(ctx.Req.Context(), uid) if err != nil { @@ -112,7 +112,7 @@ func (s *Service) handleRemove(ctx *models.ReqContext) response.Response { return response.Respond(http.StatusOK, "successfully removed the support bundle") } -func (s *Service) handleGetCollectors(ctx *models.ReqContext) response.Response { +func (s *Service) handleGetCollectors(ctx *contextmodel.ReqContext) response.Response { collectors := make([]supportbundles.Collector, 0, len(s.collectors)) for _, c := range s.collectors { diff --git a/pkg/services/team/model.go b/pkg/services/team/model.go index f7d3b347d74..c19333c8015 100644 --- a/pkg/services/team/model.go +++ b/pkg/services/team/model.go @@ -4,7 +4,7 @@ import ( "errors" "time" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/user" ) @@ -81,14 +81,14 @@ type SearchTeamsQuery struct { } type TeamDTO struct { - ID int64 `json:"id" xorm:"id"` - OrgID int64 `json:"orgId" xorm:"org_id"` - Name string `json:"name"` - Email string `json:"email"` - AvatarURL string `json:"avatarUrl"` - MemberCount int64 `json:"memberCount"` - Permission models.PermissionType `json:"permission"` - AccessControl map[string]bool `json:"accessControl"` + ID int64 `json:"id" xorm:"id"` + OrgID int64 `json:"orgId" xorm:"org_id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + MemberCount int64 `json:"memberCount"` + Permission dashboards.PermissionType `json:"permission"` + AccessControl map[string]bool `json:"accessControl"` } type SearchTeamQueryResult struct { @@ -109,7 +109,7 @@ type TeamMember struct { TeamID int64 `xorm:"team_id"` UserID int64 `xorm:"user_id"` External bool // Signals that the membership has been created by an external systems, such as LDAP - Permission models.PermissionType + Permission dashboards.PermissionType Created time.Time Updated time.Time @@ -119,18 +119,18 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { - UserID int64 `json:"userId" binding:"Required"` - OrgID int64 `json:"-"` - TeamID int64 `json:"-"` - External bool `json:"-"` - Permission models.PermissionType `json:"-"` + UserID int64 `json:"userId" binding:"Required"` + OrgID int64 `json:"-"` + TeamID int64 `json:"-"` + External bool `json:"-"` + Permission dashboards.PermissionType `json:"-"` } type UpdateTeamMemberCommand struct { - UserID int64 `json:"-"` - OrgID int64 `json:"-"` - TeamID int64 `json:"-"` - Permission models.PermissionType `json:"permission"` + UserID int64 `json:"-"` + OrgID int64 `json:"-"` + TeamID int64 `json:"-"` + Permission dashboards.PermissionType `json:"permission"` } type RemoveTeamMemberCommand struct { @@ -154,15 +154,15 @@ type GetTeamMembersQuery struct { // Projections and DTOs type TeamMemberDTO struct { - OrgID int64 `json:"orgId" xorm:"org_id"` - TeamID int64 `json:"teamId" xorm:"team_id"` - UserID int64 `json:"userId" xorm:"user_id"` - External bool `json:"-"` - AuthModule string `json:"auth_module"` - Email string `json:"email"` - Name string `json:"name"` - Login string `json:"login"` - AvatarURL string `json:"avatarUrl" xorm:"avatar_url"` - Labels []string `json:"labels"` - Permission models.PermissionType `json:"permission"` + OrgID int64 `json:"orgId" xorm:"org_id"` + TeamID int64 `json:"teamId" xorm:"team_id"` + UserID int64 `json:"userId" xorm:"user_id"` + External bool `json:"-"` + AuthModule string `json:"auth_module"` + Email string `json:"email"` + Name string `json:"name"` + Login string `json:"login"` + AvatarURL string `json:"avatarUrl" xorm:"avatar_url"` + Labels []string `json:"labels"` + Permission dashboards.PermissionType `json:"permission"` } diff --git a/pkg/services/team/team.go b/pkg/services/team/team.go index 730a5cc0aa0..9b9643894ff 100644 --- a/pkg/services/team/team.go +++ b/pkg/services/team/team.go @@ -3,7 +3,7 @@ package team import ( "context" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" ) type Service interface { @@ -13,7 +13,7 @@ type Service interface { SearchTeams(ctx context.Context, query *SearchTeamsQuery) (SearchTeamQueryResult, error) GetTeamByID(ctx context.Context, query *GetTeamByIDQuery) (*TeamDTO, error) GetTeamsByUser(ctx context.Context, query *GetTeamsByUserQuery) ([]*TeamDTO, error) - AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error + AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error UpdateTeamMember(ctx context.Context, cmd *UpdateTeamMemberCommand) error IsTeamMember(orgId int64, teamId int64, userId int64) (bool, error) RemoveTeamMember(ctx context.Context, cmd *RemoveTeamMemberCommand) error diff --git a/pkg/services/team/teamimpl/store.go b/pkg/services/team/teamimpl/store.go index c5eadcc36db..585b9c1b4b3 100644 --- a/pkg/services/team/teamimpl/store.go +++ b/pkg/services/team/teamimpl/store.go @@ -8,8 +8,8 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -22,7 +22,7 @@ type store interface { Search(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) GetByUser(ctx context.Context, query *team.GetTeamsByUserQuery) ([]*team.TeamDTO, error) - AddMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error + AddMember(userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error UpdateMember(ctx context.Context, cmd *team.UpdateTeamMemberCommand) error IsMember(orgId int64, teamId int64, userId int64) (bool, error) RemoveMember(ctx context.Context, cmd *team.RemoveTeamMemberCommand) error @@ -358,7 +358,7 @@ func (ss *xormStore) GetByUser(ctx context.Context, query *team.GetTeamsByUserQu } // AddTeamMember adds a user to a team -func (ss *xormStore) AddMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error { +func (ss *xormStore) AddMember(userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error { return ss.db.WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { if isMember, err := isTeamMember(sess, orgID, teamID, userID); err != nil { return err @@ -416,7 +416,7 @@ func isTeamMember(sess *db.Session, orgId int64, teamId int64, userId int64) (bo // AddOrUpdateTeamMemberHook is called from team resource permission service // it adds user to a team or updates user permissions in a team within the given transaction session -func AddOrUpdateTeamMemberHook(sess *db.Session, userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error { +func AddOrUpdateTeamMemberHook(sess *db.Session, userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error { isMember, err := isTeamMember(sess, orgID, teamID, userID) if err != nil { return err @@ -431,7 +431,7 @@ func AddOrUpdateTeamMemberHook(sess *db.Session, userID, orgID, teamID int64, is return err } -func addTeamMember(sess *db.Session, orgID, teamID, userID int64, isExternal bool, permission models.PermissionType) error { +func addTeamMember(sess *db.Session, orgID, teamID, userID int64, isExternal bool, permission dashboards.PermissionType) error { if _, err := teamExists(orgID, teamID, sess); err != nil { return err } @@ -450,13 +450,13 @@ func addTeamMember(sess *db.Session, orgID, teamID, userID int64, isExternal boo return err } -func updateTeamMember(sess *db.Session, orgID, teamID, userID int64, permission models.PermissionType) error { +func updateTeamMember(sess *db.Session, orgID, teamID, userID int64, permission dashboards.PermissionType) error { member, err := getTeamMember(sess, orgID, teamID, userID) if err != nil { return err } - if permission != models.PERMISSION_ADMIN { + if permission != dashboards.PERMISSION_ADMIN { permission = 0 // make sure we don't get invalid permission levels in store } @@ -590,7 +590,7 @@ func (ss *xormStore) IsAdmin(ctx context.Context, query *team.IsAdminOfTeamsQuer var queryResult bool err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { sql := "SELECT COUNT(team.id) AS count FROM team INNER JOIN team_member ON team_member.team_id = team.id WHERE team.org_id = ? AND team_member.user_id = ? AND team_member.permission = ?" - params := []interface{}{query.SignedInUser.OrgID, query.SignedInUser.UserID, models.PERMISSION_ADMIN} + params := []interface{}{query.SignedInUser.OrgID, query.SignedInUser.UserID, dashboards.PERMISSION_ADMIN} type teamCount struct { Count int64 diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 98c3bae7d78..73cdb964e69 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -165,7 +164,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { UserID: userId, OrgID: testOrgID, TeamID: team1.ID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, }) require.NoError(t, err) @@ -173,7 +172,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { qAfterUpdate := &team.GetTeamMembersQuery{OrgID: testOrgID, TeamID: team1.ID, SignedInUser: testUser} qAfterUpdateResult, err := teamSvc.GetTeamMembers(context.Background(), qAfterUpdate) require.NoError(t, err) - require.Equal(t, qAfterUpdateResult[0].Permission, models.PERMISSION_ADMIN) + require.Equal(t, qAfterUpdateResult[0].Permission, dashboards.PERMISSION_ADMIN) }) t.Run("Should default to member permission level when updating a user with invalid permission level", func(t *testing.T) { @@ -188,7 +187,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { require.NoError(t, err) require.EqualValues(t, qBeforeUpdateResult[0].Permission, 0) - invalidPermissionLevel := models.PERMISSION_EDIT + invalidPermissionLevel := dashboards.PERMISSION_EDIT err = teamSvc.UpdateTeamMember(context.Background(), &team.UpdateTeamMemberCommand{ UserID: userID, OrgID: testOrgID, @@ -211,7 +210,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { UserID: 1, OrgID: testOrgID, TeamID: team1.ID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, }) require.Error(t, err, team.ErrTeamMemberNotFound) @@ -267,7 +266,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should have empty teams", func(t *testing.T) { - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, models.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, dashboards.PERMISSION_ADMIN) require.NoError(t, err) t.Run("A user should be able to remove the admin permission for the last admin", func(t *testing.T) { @@ -284,10 +283,10 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { sqlStore = db.InitTestDB(t) setup() - err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, models.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(userIds[0], testOrgID, team1.ID, false, dashboards.PERMISSION_ADMIN) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[1], testOrgID, team1.ID, false, models.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(userIds[1], testOrgID, team1.ID, false, dashboards.PERMISSION_ADMIN) require.NoError(t, err) err = teamSvc.UpdateTeamMember(context.Background(), &team.UpdateTeamMemberCommand{OrgID: testOrgID, TeamID: team1.ID, UserID: userIds[0], Permission: 0}) require.NoError(t, err) @@ -301,7 +300,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { err = teamSvc.AddTeamMember(userIds[2], testOrgID, groupID, false, 0) require.NoError(t, err) err = updateDashboardACL(t, sqlStore, 1, &dashboards.DashboardACL{ - DashboardID: 1, OrgID: testOrgID, Permission: models.PERMISSION_EDIT, TeamID: groupID, + DashboardID: 1, OrgID: testOrgID, Permission: dashboards.PERMISSION_EDIT, TeamID: groupID, }) require.NoError(t, err) err = teamSvc.DeleteTeam(context.Background(), &team.DeleteTeamCommand{OrgID: testOrgID, ID: groupID}) @@ -312,10 +311,10 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { require.Equal(t, err, team.ErrTeamNotFound) permQuery := &dashboards.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: testOrgID} - err = getDashboardACLInfoList(sqlStore, permQuery) + permQueryResult, err := getDashboardACLInfoList(sqlStore, permQuery) require.NoError(t, err) - require.Equal(t, len(permQuery.Result), 0) + require.Equal(t, len(permQueryResult), 0) }) t.Run("Should be able to return if user is admin of teams or not", func(t *testing.T) { @@ -324,7 +323,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { groupId := team2.ID err := teamSvc.AddTeamMember(userIds[0], testOrgID, groupId, false, 0) require.NoError(t, err) - err = teamSvc.AddTeamMember(userIds[1], testOrgID, groupId, false, models.PERMISSION_ADMIN) + err = teamSvc.AddTeamMember(userIds[1], testOrgID, groupId, false, dashboards.PERMISSION_ADMIN) require.NoError(t, err) query := &team.IsAdminOfTeamsQuery{SignedInUser: &user.SignedInUser{OrgID: testOrgID, UserID: userIds[0]}} @@ -630,11 +629,11 @@ func updateDashboardACL(t *testing.T, sqlStore *sqlstore.SQLStore, dashboardID i item.Created = time.Now() item.Updated = time.Now() if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing + return dashboards.ErrDashboardACLInfoMissing } if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty + return dashboards.ErrDashboardPermissionDashboardEmpty } sess.Nullable("user_id", "team_id") @@ -654,9 +653,9 @@ func updateDashboardACL(t *testing.T, sqlStore *sqlstore.SQLStore, dashboardID i // 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 -func getDashboardACLInfoList(s *sqlstore.SQLStore, query *dashboards.GetDashboardACLInfoListQuery) error { +func getDashboardACLInfoList(s *sqlstore.SQLStore, query *dashboards.GetDashboardACLInfoListQuery) ([]*dashboards.DashboardACLInfoDTO, error) { + queryResult := make([]*dashboards.DashboardACLInfoDTO, 0) outerErr := s.WithDbSession(context.Background(), func(dbSession *db.Session) error { - query.Result = make([]*dashboards.DashboardACLInfoDTO, 0) falseStr := s.GetDialect().BooleanStr(false) if query.DashboardID == 0 { @@ -680,7 +679,7 @@ func getDashboardACLInfoList(s *sqlstore.SQLStore, query *dashboards.GetDashboar falseStr + ` AS inherited FROM dashboard_acl as da WHERE da.dashboard_id = -1` - return dbSession.SQL(sql).Find(&query.Result) + return dbSession.SQL(sql).Find(&queryResult) } rawSQL := ` @@ -722,16 +721,16 @@ func getDashboardACLInfoList(s *sqlstore.SQLStore, query *dashboards.GetDashboar ORDER BY da.id ASC ` - return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&query.Result) + return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&queryResult) }) if outerErr != nil { - return outerErr + return nil, outerErr } - for _, p := range query.Result { + for _, p := range queryResult { p.PermissionName = p.Permission.String() } - return nil + return queryResult, nil } diff --git a/pkg/services/team/teamimpl/team.go b/pkg/services/team/teamimpl/team.go index f9a33d0715f..31ce4295cf5 100644 --- a/pkg/services/team/teamimpl/team.go +++ b/pkg/services/team/teamimpl/team.go @@ -4,7 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/setting" ) @@ -41,7 +41,7 @@ func (s *Service) GetTeamsByUser(ctx context.Context, query *team.GetTeamsByUser return s.store.GetByUser(ctx, query) } -func (s *Service) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error { +func (s *Service) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error { return s.store.AddMember(userID, orgID, teamID, isExternal, permission) } diff --git a/pkg/services/team/teamtest/team.go b/pkg/services/team/teamtest/team.go index 5baf567c5e1..332e9bce0af 100644 --- a/pkg/services/team/teamtest/team.go +++ b/pkg/services/team/teamtest/team.go @@ -3,7 +3,7 @@ package teamtest import ( "context" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/team" ) @@ -44,7 +44,7 @@ func (s *FakeService) GetTeamsByUser(ctx context.Context, query *team.GetTeamsBy return s.ExpectedTeamsByUser, s.ExpectedError } -func (s *FakeService) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error { +func (s *FakeService) AddTeamMember(userID, orgID, teamID int64, isExternal bool, permission dashboards.PermissionType) error { return s.ExpectedError } diff --git a/pkg/services/teamguardian/manager/service.go b/pkg/services/teamguardian/manager/service.go index 9c60116c639..a21018c8302 100644 --- a/pkg/services/teamguardian/manager/service.go +++ b/pkg/services/teamguardian/manager/service.go @@ -3,7 +3,7 @@ package manager import ( "context" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/teamguardian" @@ -40,7 +40,7 @@ func (s *Service) CanAdmin(ctx context.Context, orgId int64, teamId int64, user } for _, member := range results { - if member.UserID == user.UserID && member.Permission == models.PERMISSION_ADMIN { + if member.UserID == user.UserID && member.Permission == dashboards.PERMISSION_ADMIN { return nil } } diff --git a/pkg/services/teamguardian/manager/service_test.go b/pkg/services/teamguardian/manager/service_test.go index abde850dadb..d4da1f1c5f5 100644 --- a/pkg/services/teamguardian/manager/service_test.go +++ b/pkg/services/teamguardian/manager/service_test.go @@ -4,13 +4,14 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/teamguardian/database" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func TestUpdateTeam(t *testing.T) { @@ -50,7 +51,7 @@ func TestUpdateTeam(t *testing.T) { OrgID: testTeam.OrgID, TeamID: testTeam.ID, UserID: editor.UserID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, }} store.On("GetTeamMembers", ctx, mock.Anything).Return(result, nil).Once() @@ -72,7 +73,7 @@ func TestUpdateTeam(t *testing.T) { OrgID: testTeamOtherOrg.OrgID, TeamID: testTeamOtherOrg.ID, UserID: editor.UserID, - Permission: models.PERMISSION_ADMIN, + Permission: dashboards.PERMISSION_ADMIN, }} store.On("GetTeamMembers", ctx, mock.Anything).Return(result, nil).Once() diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go index 09d2045cb5b..9f77fe37891 100644 --- a/pkg/services/thumbs/dummy.go +++ b/pkg/services/thumbs/dummy.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) // When the feature flag is not enabled we just implement a dummy service @@ -15,15 +15,15 @@ func (ds *dummyService) GetUsageStats(ctx context.Context) map[string]interface{ return make(map[string]interface{}) } -func (ds *dummyService) GetImage(c *models.ReqContext) { +func (ds *dummyService) GetImage(c *contextmodel.ReqContext) { c.JSON(400, map[string]string{"error": "invalid size"}) } -func (ds *dummyService) UpdateThumbnailState(c *models.ReqContext) { +func (ds *dummyService) UpdateThumbnailState(c *contextmodel.ReqContext) { c.JSON(400, map[string]string{"error": "invalid size"}) } -func (ds *dummyService) SetImage(c *models.ReqContext) { +func (ds *dummyService) SetImage(c *contextmodel.ReqContext) { c.JSON(400, map[string]string{"error": "invalid size"}) } @@ -31,7 +31,7 @@ func (ds *dummyService) Enabled() bool { return false } -func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig { +func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *contextmodel.ReqContext) dashboardPreviewsSetupConfig { return dashboardPreviewsSetupConfig{ SystemRequirements: dashboardPreviewsSystemRequirements{ Met: false, @@ -41,19 +41,19 @@ func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) } } -func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response { +func (ds *dummyService) StartCrawler(c *contextmodel.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" return response.JSON(http.StatusOK, result) } -func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response { +func (ds *dummyService) StopCrawler(c *contextmodel.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" return response.JSON(http.StatusOK, result) } -func (ds *dummyService) CrawlerStatus(c *models.ReqContext) response.Response { +func (ds *dummyService) CrawlerStatus(c *contextmodel.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" return response.JSON(http.StatusOK, result) diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go index da15dc8a1d2..f34eb8af566 100644 --- a/pkg/services/thumbs/service.go +++ b/pkg/services/thumbs/service.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -32,17 +33,17 @@ type Service interface { registry.ProvidesUsageStats Run(ctx context.Context) error Enabled() bool - GetImage(c *models.ReqContext) - GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig + GetImage(c *contextmodel.ReqContext) + GetDashboardPreviewsSetupSettings(c *contextmodel.ReqContext) dashboardPreviewsSetupConfig // from dashboard page - SetImage(c *models.ReqContext) // form post - UpdateThumbnailState(c *models.ReqContext) + SetImage(c *contextmodel.ReqContext) // form post + UpdateThumbnailState(c *contextmodel.ReqContext) // Must be admin - StartCrawler(c *models.ReqContext) response.Response - StopCrawler(c *models.ReqContext) response.Response - CrawlerStatus(c *models.ReqContext) response.Response + StartCrawler(c *contextmodel.ReqContext) response.Response + StopCrawler(c *contextmodel.ReqContext) response.Response + CrawlerStatus(c *contextmodel.ReqContext) response.Response } type thumbService struct { @@ -154,7 +155,7 @@ func (hs *thumbService) Enabled() bool { return hs.features.IsEnabled(featuremgmt.FlagDashboardPreviews) } -func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest { +func (hs *thumbService) parseImageReq(c *contextmodel.ReqContext, checkSave bool) *previewRequest { params := web.Params(c.Req) kind, err := ParseThumbnailKind(params[":kind"]) @@ -199,7 +200,7 @@ type updateThumbnailStateRequest struct { State ThumbnailState `json:"state" binding:"Required"` } -func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) { +func (hs *thumbService) UpdateThumbnailState(c *contextmodel.ReqContext) { req := hs.parseImageReq(c, false) if req == nil { return // already returned value @@ -231,7 +232,7 @@ func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) { c.JSON(http.StatusOK, map[string]string{"success": "true"}) } -func (hs *thumbService) GetImage(c *models.ReqContext) { +func (hs *thumbService) GetImage(c *contextmodel.ReqContext) { req := hs.parseImageReq(c, false) if req == nil { return // already returned value @@ -274,7 +275,7 @@ func (hs *thumbService) GetImage(c *models.ReqContext) { } } -func (hs *thumbService) hasAccessToPreview(c *models.ReqContext, res *DashboardThumbnail, req *previewRequest) bool { +func (hs *thumbService) hasAccessToPreview(c *contextmodel.ReqContext, res *DashboardThumbnail, req *previewRequest) bool { if !hs.licensing.FeatureEnabled("accesscontrol.enforcement") { return true } @@ -318,7 +319,7 @@ func (hs *thumbService) hasAccessToPreview(c *models.ReqContext, res *DashboardT return true } -func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig { +func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *contextmodel.ReqContext) dashboardPreviewsSetupConfig { return hs.getDashboardPreviewsSetupSettings(c.Req.Context()) } @@ -361,7 +362,7 @@ func (hs *thumbService) getSystemRequirements(ctx context.Context) dashboardPrev } // Hack for now -- lets you upload images explicitly -func (hs *thumbService) SetImage(c *models.ReqContext) { +func (hs *thumbService) SetImage(c *contextmodel.ReqContext) { req := hs.parseImageReq(c, false) if req == nil { return // already returned value @@ -423,7 +424,7 @@ func (hs *thumbService) SetImage(c *models.ReqContext) { c.JSON(http.StatusOK, map[string]int{"OK": len(fileBytes)}) } -func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { +func (hs *thumbService) StartCrawler(c *contextmodel.ReqContext) response.Response { body, err := io.ReadAll(c.Req.Body) if err != nil { return response.Error(500, "error reading bytes", err) @@ -451,7 +452,7 @@ func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, status) } -func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { +func (hs *thumbService) StopCrawler(c *contextmodel.ReqContext) response.Response { msg, err := hs.renderer.Stop() if err != nil { return response.Error(500, "error starting", err) @@ -459,7 +460,7 @@ func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, msg) } -func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response { +func (hs *thumbService) CrawlerStatus(c *contextmodel.ReqContext) response.Response { msg, err := hs.renderer.Status() if err != nil { return response.Error(500, "error starting", err) @@ -468,7 +469,7 @@ func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response { } // Ideally this service would not require first looking up the full dashboard just to bet the id! -func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) (int, error) { +func (hs *thumbService) getStatus(c *contextmodel.ReqContext, uid string, checkSave bool) (int, error) { guardian, err := guardian.NewByUID(c.Req.Context(), uid, c.OrgID, c.SignedInUser) if err != nil { return 0, err diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 97a3bb58b4e..ec86278bde9 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -10,7 +10,6 @@ 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/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -284,7 +283,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { err = updateDashboardACL(t, ss, 1, &dashboards.DashboardACL{ DashboardID: 1, OrgID: users[0].OrgID, UserID: users[1].ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) @@ -423,7 +422,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { err = updateDashboardACL(t, ss, 1, &dashboards.DashboardACL{ DashboardID: 1, OrgID: users[0].OrgID, UserID: users[1].ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) @@ -432,10 +431,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) permQuery := &dashboards.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: users[0].OrgID} - err = userStore.getDashboardACLInfoList(permQuery) + permQueryResult, err := userStore.getDashboardACLInfoList(permQuery) require.Nil(t, err) - require.Len(t, permQuery.Result, 0) + require.Len(t, permQueryResult, 0) // A user is an org member and has been assigned permissions // Re-init DB @@ -457,7 +456,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { err = updateDashboardACL(t, ss, 1, &dashboards.DashboardACL{ DashboardID: 1, OrgID: users[0].OrgID, UserID: users[1].ID, - Permission: models.PERMISSION_EDIT, + Permission: dashboards.PERMISSION_EDIT, }) require.Nil(t, err) @@ -488,10 +487,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) permQuery = &dashboards.GetDashboardACLInfoListQuery{DashboardID: 1, OrgID: users[0].OrgID} - err = userStore.getDashboardACLInfoList(permQuery) + permQueryResult, err = userStore.getDashboardACLInfoList(permQuery) require.Nil(t, err) - require.Len(t, permQuery.Result, 0) + require.Len(t, permQueryResult, 0) }) t.Run("Testing DB - return list of users that the SignedInUser has permission to read", func(t *testing.T) { @@ -831,11 +830,11 @@ func updateDashboardACL(t *testing.T, sqlStore db.DB, dashboardID int64, items . item.Created = time.Now() item.Updated = time.Now() if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing + return dashboards.ErrDashboardACLInfoMissing } if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty + return dashboards.ErrDashboardPermissionDashboardEmpty } sess.Nullable("user_id", "team_id") @@ -855,9 +854,9 @@ func updateDashboardACL(t *testing.T, sqlStore db.DB, dashboardID int64, items . // 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 -func (ss *sqlStore) getDashboardACLInfoList(query *dashboards.GetDashboardACLInfoListQuery) error { +func (ss *sqlStore) getDashboardACLInfoList(query *dashboards.GetDashboardACLInfoListQuery) ([]*dashboards.DashboardACLInfoDTO, error) { + queryResult := make([]*dashboards.DashboardACLInfoDTO, 0) outerErr := ss.db.WithDbSession(context.Background(), func(dbSession *db.Session) error { - query.Result = make([]*dashboards.DashboardACLInfoDTO, 0) falseStr := ss.dialect.BooleanStr(false) if query.DashboardID == 0 { @@ -881,7 +880,7 @@ func (ss *sqlStore) getDashboardACLInfoList(query *dashboards.GetDashboardACLInf falseStr + ` AS inherited FROM dashboard_acl as da WHERE da.dashboard_id = -1` - return dbSession.SQL(sql).Find(&query.Result) + return dbSession.SQL(sql).Find(&queryResult) } rawSQL := ` @@ -923,18 +922,18 @@ func (ss *sqlStore) getDashboardACLInfoList(query *dashboards.GetDashboardACLInf ORDER BY da.id ASC ` - return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&query.Result) + return dbSession.SQL(rawSQL, query.OrgID, query.DashboardID).Find(&queryResult) }) if outerErr != nil { - return outerErr + return nil, outerErr } - for _, p := range query.Result { + for _, p := range queryResult { p.PermissionName = p.Permission.String() } - return nil + return queryResult, nil } func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8fac999f44a..b2b1feeb842 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -87,12 +87,6 @@ var ( CookieSameSiteDisabled bool CookieSameSiteMode http.SameSite - // Snapshots - ExternalSnapshotUrl string - ExternalSnapshotName string - ExternalEnabled bool - SnapShotRemoveExpired bool - // Dashboard history DashboardVersionsToKeep int MinRefreshInterval string @@ -282,6 +276,8 @@ type Cfg struct { PluginAdminEnabled bool PluginAdminExternalManageEnabled bool + PluginsCDNURLTemplate string + // Panels DisableSanitizeHtml bool @@ -407,6 +403,12 @@ type Cfg struct { DataSourceLimit int // Snapshots + SnapshotEnabled bool + ExternalSnapshotUrl string + ExternalSnapshotName string + ExternalEnabled bool + SnapShotRemoveExpired bool + SnapshotPublicMode bool ErrTemplateName string @@ -430,6 +432,9 @@ type Cfg struct { // Google GoogleSkipOrgRoleSync bool + // Gitlab + GitLabSkipOrgRoleSync bool + // LDAP LDAPEnabled bool LDAPSkipOrgRoleSync bool @@ -464,6 +469,9 @@ type Cfg struct { // then Live uses AppURL as the only allowed origin. LiveAllowedOrigins []string + // Github OAuth + GithubSkipOrgRoleSync bool + // Grafana.com URL, used for OAuth redirect. GrafanaComURL string // Grafana.com API URL. Can be set separately to GrafanaComURL @@ -1372,11 +1380,21 @@ func readAuthGrafanaComSettings(iniFile *ini.File, cfg *Cfg) { cfg.GrafanaComSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(false) } +func readAuthGithubSettings(iniFile *ini.File, cfg *Cfg) { + sec := iniFile.Section("auth.github") + cfg.GithubSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(false) +} + func readAuthGoogleSettings(iniFile *ini.File, cfg *Cfg) { sec := iniFile.Section("auth.google") cfg.GoogleSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(false) } +func readAuthGitlabSettings(iniFile *ini.File, cfg *Cfg) { + sec := iniFile.Section("auth.gitlab") + cfg.GitLabSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(false) +} + func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { auth := iniFile.Section("auth") @@ -1434,6 +1452,9 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { // Google Auth readAuthGoogleSettings(iniFile, cfg) + // GitLab Auth + readAuthGitlabSettings(iniFile, cfg) + // anonymous access AnonymousEnabled = iniFile.Section("auth.anonymous").Key("enabled").MustBool(false) cfg.AnonymousEnabled = AnonymousEnabled @@ -1490,7 +1511,11 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { cfg.AuthProxyHeadersEncoded = authProxy.Key("headers_encoded").MustBool(false) + // GrafanaCom readAuthGrafanaComSettings(iniFile, cfg) + + // Github + readAuthGithubSettings(iniFile, cfg) return nil } @@ -1679,11 +1704,13 @@ func IsLegacyAlertingEnabled() bool { func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { snapshots := iniFile.Section("snapshots") - ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "") - ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "") + cfg.SnapshotEnabled = snapshots.Key("enabled").MustBool(true) - ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) - SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) + cfg.ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "") + cfg.ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "") + + cfg.ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) + cfg.SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) cfg.SnapshotPublicMode = snapshots.Key("public_mode").MustBool(false) return nil diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 95d5f0e7fd0..8c29a2e52ff 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -26,6 +26,7 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings { func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { pluginsSection := iniFile.Section("plugins") + cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false) cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false) cfg.PluginSettings = extractPluginSettings(iniFile.Sections()) @@ -47,5 +48,8 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug) } + // Plugins CDN settings + cfg.PluginsCDNURLTemplate = strings.TrimRight(pluginsSection.Key("cdn_base_url").MustString(""), "/") + return nil } diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 7396e24dbaf..ce6323085c5 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -98,7 +98,8 @@ func TestIntegrationTestReceivers(t *testing.T) { "type": "email", "disableResolveMessage": false, "settings": { - "addresses":"example@email.com" + "addresses":"example@email.com", + "singleEmail": true }, "secureFields": {} } diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index e8337cebf39..b3c930afb2d 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -1437,7 +1437,7 @@ "signatureOrg": "" }, { - "name": "TestData DB", + "name": "TestData", "type": "datasource", "id": "testdata", "enabled": true, diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index 8ac01ea8620..b2e212ba720 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -236,6 +236,11 @@ type TermsAggregation struct { Missing *string `json:"missing,omitempty"` } +// NestedAggregation represents a nested aggregation +type NestedAggregation struct { + Path string `json:"path"` +} + // ExtendedBounds represents extended bounds type ExtendedBounds struct { Min int64 `json:"min"` diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go index f4a4ce97755..e3122e5974b 100644 --- a/pkg/tsdb/elasticsearch/client/search_request.go +++ b/pkg/tsdb/elasticsearch/client/search_request.go @@ -270,6 +270,7 @@ type AggBuilder interface { Histogram(key, field string, fn func(a *HistogramAgg, b AggBuilder)) AggBuilder DateHistogram(key, field string, fn func(a *DateHistogramAgg, b AggBuilder)) AggBuilder Terms(key, field string, fn func(a *TermsAggregation, b AggBuilder)) AggBuilder + Nested(key, path string, fn func(a *NestedAggregation, b AggBuilder)) AggBuilder Filters(key string, fn func(a *FiltersAggregation, b AggBuilder)) AggBuilder GeoHashGrid(key, field string, fn func(a *GeoHashGridAggregation, b AggBuilder)) AggBuilder Metric(key, metricType, field string, fn func(a *MetricAggregation)) AggBuilder @@ -382,6 +383,26 @@ func (b *aggBuilderImpl) Terms(key, field string, fn func(a *TermsAggregation, b return b } +func (b *aggBuilderImpl) Nested(key, field string, fn func(a *NestedAggregation, b AggBuilder)) AggBuilder { + innerAgg := &NestedAggregation{ + Path: field, + } + aggDef := newAggDef(key, &aggContainer{ + Type: "nested", + Aggregation: innerAgg, + }) + + if fn != nil { + builder := newAggBuilder() + aggDef.builders = append(aggDef.builders, builder) + fn(innerAgg, builder) + } + + b.aggDefs = append(b.aggDefs, aggDef) + + return b +} + func (b *aggBuilderImpl) Filters(key string, fn func(a *FiltersAggregation, b AggBuilder)) AggBuilder { innerAgg := &FiltersAggregation{ Filters: make(map[string]interface{}), diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 50b6adcd822..5c154f9c09c 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -22,6 +22,7 @@ const ( topMetricsType = "top_metrics" // Bucket types dateHistType = "date_histogram" + nestedType = "nested" histogramType = "histogram" filtersType = "filters" termsType = "terms" @@ -84,6 +85,13 @@ func processBuckets(aggs map[string]interface{}, target *Query, if aggDef == nil { continue } + if aggDef.Type == nestedType { + err = processBuckets(esAgg.MustMap(), target, queryResult, props, depth+1) + if err != nil { + return err + } + continue + } if depth == maxDepth { if aggDef.Type == dateHistType { diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index d1e50e5f169..2e88a92e3b9 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -244,6 +244,14 @@ func addTermsAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, metrics []*Metr return aggBuilder } +func addNestedAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg) es.AggBuilder { + aggBuilder.Nested(bucketAgg.ID, bucketAgg.Field, func(a *es.NestedAggregation, b es.AggBuilder) { + aggBuilder = b + }) + + return aggBuilder +} + func addFiltersAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg) es.AggBuilder { filters := make(map[string]interface{}) for _, filter := range bucketAgg.Settings.Get("filters").MustArray() { @@ -361,6 +369,8 @@ func processTimeSeriesQuery(q *Query, b *es.SearchRequestBuilder, from, to int64 aggBuilder = addTermsAgg(aggBuilder, bucketAgg, q.Metrics) case geohashGridType: aggBuilder = addGeoHashGridAgg(aggBuilder, bucketAgg) + case nestedType: + aggBuilder = addNestedAgg(aggBuilder, bucketAgg) } } diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index a8835da56af..e18752383d2 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -85,8 +85,11 @@ func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*h return nil, err } - if query.VolumeQuery { - req.Header.Set("X-Query-Tags", "Source=logvolhist") + if query.SupportingQueryType != SupportingQueryNone { + value := getSupportingQueryHeaderValue(req, query.SupportingQueryType) + if value != "" { + req.Header.Set("X-Query-Tags", "Source="+value) + } } return req, nil @@ -223,3 +226,18 @@ func (api *LokiAPI) RawQuery(ctx context.Context, resourcePath string) (RawLokiR return encodedBytes, nil } + +func getSupportingQueryHeaderValue(req *http.Request, supportingQueryType SupportingQueryType) string { + value := "" + switch supportingQueryType { + case SupportingQueryLogsVolume: + value = "logvolhist" + case SupportingQueryLogsSample: + value = "logsample" + case SupportingQueryDataSample: + value = "datasample" + default: //ignore + } + + return value +} diff --git a/pkg/tsdb/loki/api_test.go b/pkg/tsdb/loki/api_test.go index 2b64e383bf4..b28d159d895 100644 --- a/pkg/tsdb/loki/api_test.go +++ b/pkg/tsdb/loki/api_test.go @@ -28,7 +28,31 @@ func TestApiLogVolume(t *testing.T) { require.Equal(t, "Source=logvolhist", req.Header.Get("X-Query-Tags")) }) - _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", VolumeQuery: true, QueryType: QueryTypeRange}) + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsVolume, QueryType: QueryTypeRange}) + require.NoError(t, err) + require.True(t, called) + }) + + t.Run("logs sample queries should set logs sample http header", func(t *testing.T) { + called := false + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, "Source=logsample", req.Header.Get("X-Query-Tags")) + }) + + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsSample, QueryType: QueryTypeRange}) + require.NoError(t, err) + require.True(t, called) + }) + + t.Run("data sample queries should set data sample http header", func(t *testing.T) { + called := false + api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { + called = true + require.Equal(t, "Source=datasample", req.Header.Get("X-Query-Tags")) + }) + + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryDataSample, QueryType: QueryTypeRange}) require.NoError(t, err) require.True(t, called) }) @@ -40,7 +64,7 @@ func TestApiLogVolume(t *testing.T) { require.Equal(t, "", req.Header.Get("X-Query-Tags")) }) - _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", VolumeQuery: false, QueryType: QueryTypeRange}) + _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryNone, QueryType: QueryTypeRange}) require.NoError(t, err) require.True(t, called) }) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 6925a91c25b..06c57a90924 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -57,15 +57,15 @@ type datasourceInfo struct { } type QueryJSONModel struct { - QueryType string `json:"queryType"` - Expr string `json:"expr"` - Direction string `json:"direction"` - LegendFormat string `json:"legendFormat"` - Interval string `json:"interval"` - IntervalMS int `json:"intervalMS"` - Resolution int64 `json:"resolution"` - MaxLines int `json:"maxLines"` - VolumeQuery bool `json:"volumeQuery"` + QueryType string `json:"queryType"` + Expr string `json:"expr"` + Direction string `json:"direction"` + LegendFormat string `json:"legendFormat"` + Interval string `json:"interval"` + IntervalMS int `json:"intervalMS"` + Resolution int64 `json:"resolution"` + MaxLines int `json:"maxLines"` + SupportingQueryType string `json:"supportingQueryType"` } func parseQueryModel(raw json.RawMessage) (*QueryJSONModel, error) { @@ -112,7 +112,8 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender } if (!strings.HasPrefix(url, "labels?")) && (!strings.HasPrefix(url, "label/")) && // the `/label/$label_name/values` form - (!strings.HasPrefix(url, "series?")) { + (!strings.HasPrefix(url, "series?")) && + (!strings.HasPrefix(url, "index/stats?")) { return fmt.Errorf("invalid resource URL: %s", url) } lokiURL := fmt.Sprintf("/loki/api/v1/%s", url) diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index 80f0c0cbc27..5e4434bfce0 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -82,6 +82,21 @@ func parseDirection(jsonValue string) (Direction, error) { } } +func parseSupportingQueryType(jsonValue string) (SupportingQueryType, error) { + switch jsonValue { + case "logsVolume": + return SupportingQueryLogsVolume, nil + case "logsSample": + return SupportingQueryLogsSample, nil + case "dataSample": + return SupportingQueryDataSample, nil + case "": + return SupportingQueryNone, nil + default: + return SupportingQueryNone, fmt.Errorf("invalid supportingQueryType: %s", jsonValue) + } +} + func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { qs := []*lokiQuery{} for _, query := range queryContext.Queries { @@ -115,17 +130,22 @@ func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { return nil, err } + supportingQueryType, err := parseSupportingQueryType(model.SupportingQueryType) + if err != nil { + return nil, err + } + qs = append(qs, &lokiQuery{ - Expr: expr, - QueryType: queryType, - Direction: direction, - Step: step, - MaxLines: model.MaxLines, - LegendFormat: model.LegendFormat, - Start: start, - End: end, - RefID: query.RefID, - VolumeQuery: model.VolumeQuery, + Expr: expr, + QueryType: queryType, + Direction: direction, + Step: step, + MaxLines: model.MaxLines, + LegendFormat: model.LegendFormat, + Start: start, + End: end, + RefID: query.RefID, + SupportingQueryType: supportingQueryType, }) } diff --git a/pkg/tsdb/loki/types.go b/pkg/tsdb/loki/types.go index 9d5fcead26d..c2f1b130fb5 100644 --- a/pkg/tsdb/loki/types.go +++ b/pkg/tsdb/loki/types.go @@ -3,12 +3,20 @@ package loki import "time" type QueryType string +type SupportingQueryType string const ( QueryTypeRange QueryType = "range" QueryTypeInstant QueryType = "instant" ) +const ( + SupportingQueryLogsVolume SupportingQueryType = "logsVolume" + SupportingQueryLogsSample SupportingQueryType = "logsSample" + SupportingQueryDataSample SupportingQueryType = "dataSample" + SupportingQueryNone SupportingQueryType = "none" +) + type Direction string const ( @@ -17,14 +25,14 @@ const ( ) type lokiQuery struct { - Expr string - QueryType QueryType - Direction Direction - Step time.Duration - MaxLines int - LegendFormat string - Start time.Time - End time.Time - RefID string - VolumeQuery bool + Expr string + QueryType QueryType + Direction Direction + Step time.Duration + MaxLines int + LegendFormat string + Start time.Time + End time.Time + RefID string + SupportingQueryType SupportingQueryType } diff --git a/pkg/tsdb/testdatasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/testdatasource/kinds/dataquery/types_dataquery_gen.go new file mode 100644 index 00000000000..92f9ba53b1e --- /dev/null +++ b/pkg/tsdb/testdatasource/kinds/dataquery/types_dataquery_gen.go @@ -0,0 +1,338 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// public/app/plugins/gen.go +// Using jennies: +// PluginGoTypesJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package dataquery + +// Defines values for NodesQueryType. +const ( + NodesQueryTypeRandom NodesQueryType = "random" + + NodesQueryTypeRandomEdges NodesQueryType = "random edges" + + NodesQueryTypeResponse NodesQueryType = "response" +) + +// Defines values for StreamingQueryType. +const ( + StreamingQueryTypeFetch StreamingQueryType = "fetch" + + StreamingQueryTypeLogs StreamingQueryType = "logs" + + StreamingQueryTypeSignal StreamingQueryType = "signal" +) + +// Defines values for ErrorType. +const ( + ErrorTypeFrontendException ErrorType = "frontend_exception" + + ErrorTypeFrontendObservable ErrorType = "frontend_observable" + + ErrorTypeServerPanic ErrorType = "server_panic" +) + +// Defines values for NodesType. +const ( + NodesTypeRandom NodesType = "random" + + NodesTypeRandomEdges NodesType = "random edges" + + NodesTypeResponse NodesType = "response" +) + +// Defines values for ScenarioId. +const ( + ScenarioIdAnnotations ScenarioId = "annotations" + + ScenarioIdArrow ScenarioId = "arrow" + + ScenarioIdCsvContent ScenarioId = "csv_content" + + ScenarioIdCsvFile ScenarioId = "csv_file" + + ScenarioIdCsvMetricValues ScenarioId = "csv_metric_values" + + ScenarioIdDatapointsOutsideRange ScenarioId = "datapoints_outside_range" + + ScenarioIdExponentialHeatmapBucketData ScenarioId = "exponential_heatmap_bucket_data" + + ScenarioIdFlameGraph ScenarioId = "flame_graph" + + ScenarioIdGrafanaApi ScenarioId = "grafana_api" + + ScenarioIdLinearHeatmapBucketData ScenarioId = "linear_heatmap_bucket_data" + + ScenarioIdLive ScenarioId = "live" + + ScenarioIdLogs ScenarioId = "logs" + + ScenarioIdManualEntry ScenarioId = "manual_entry" + + ScenarioIdNoDataPoints ScenarioId = "no_data_points" + + ScenarioIdNodeGraph ScenarioId = "node_graph" + + ScenarioIdPredictableCsvWave ScenarioId = "predictable_csv_wave" + + ScenarioIdPredictablePulse ScenarioId = "predictable_pulse" + + ScenarioIdRandomWalk ScenarioId = "random_walk" + + ScenarioIdRandomWalkTable ScenarioId = "random_walk_table" + + ScenarioIdRandomWalkWithError ScenarioId = "random_walk_with_error" + + ScenarioIdRawFrame ScenarioId = "raw_frame" + + ScenarioIdServerError500 ScenarioId = "server_error_500" + + ScenarioIdSimulation ScenarioId = "simulation" + + ScenarioIdSlowQuery ScenarioId = "slow_query" + + ScenarioIdStreamingClient ScenarioId = "streaming_client" + + ScenarioIdTableStatic ScenarioId = "table_static" + + ScenarioIdTrace ScenarioId = "trace" + + ScenarioIdUsa ScenarioId = "usa" + + ScenarioIdVariablesQuery ScenarioId = "variables-query" +) + +// Defines values for StreamType. +const ( + StreamTypeFetch StreamType = "fetch" + + StreamTypeLogs StreamType = "logs" + + StreamTypeSignal StreamType = "signal" +) + +// Defines values for TestDataQueryType. +const ( + TestDataQueryTypeAnnotations TestDataQueryType = "annotations" + + TestDataQueryTypeArrow TestDataQueryType = "arrow" + + TestDataQueryTypeCsvContent TestDataQueryType = "csv_content" + + TestDataQueryTypeCsvFile TestDataQueryType = "csv_file" + + TestDataQueryTypeCsvMetricValues TestDataQueryType = "csv_metric_values" + + TestDataQueryTypeDatapointsOutsideRange TestDataQueryType = "datapoints_outside_range" + + TestDataQueryTypeExponentialHeatmapBucketData TestDataQueryType = "exponential_heatmap_bucket_data" + + TestDataQueryTypeFlameGraph TestDataQueryType = "flame_graph" + + TestDataQueryTypeGrafanaApi TestDataQueryType = "grafana_api" + + TestDataQueryTypeLinearHeatmapBucketData TestDataQueryType = "linear_heatmap_bucket_data" + + TestDataQueryTypeLive TestDataQueryType = "live" + + TestDataQueryTypeLogs TestDataQueryType = "logs" + + TestDataQueryTypeManualEntry TestDataQueryType = "manual_entry" + + TestDataQueryTypeNoDataPoints TestDataQueryType = "no_data_points" + + TestDataQueryTypeNodeGraph TestDataQueryType = "node_graph" + + TestDataQueryTypePredictableCsvWave TestDataQueryType = "predictable_csv_wave" + + TestDataQueryTypePredictablePulse TestDataQueryType = "predictable_pulse" + + TestDataQueryTypeRandomWalk TestDataQueryType = "random_walk" + + TestDataQueryTypeRandomWalkTable TestDataQueryType = "random_walk_table" + + TestDataQueryTypeRandomWalkWithError TestDataQueryType = "random_walk_with_error" + + TestDataQueryTypeRawFrame TestDataQueryType = "raw_frame" + + TestDataQueryTypeServerError500 TestDataQueryType = "server_error_500" + + TestDataQueryTypeSimulation TestDataQueryType = "simulation" + + TestDataQueryTypeSlowQuery TestDataQueryType = "slow_query" + + TestDataQueryTypeStreamingClient TestDataQueryType = "streaming_client" + + TestDataQueryTypeTableStatic TestDataQueryType = "table_static" + + TestDataQueryTypeTrace TestDataQueryType = "trace" + + TestDataQueryTypeUsa TestDataQueryType = "usa" + + TestDataQueryTypeVariablesQuery TestDataQueryType = "variables-query" +) + +// CSVWave defines model for CSVWave. +type CSVWave struct { + Labels *string `json:"labels,omitempty"` + Name *string `json:"name,omitempty"` + TimeStep *int64 `json:"timeStep,omitempty"` + ValuesCSV *string `json:"valuesCSV,omitempty"` +} + +// NodesQuery defines model for NodesQuery. +type NodesQuery struct { + Count *int64 `json:"count,omitempty"` + Type *NodesQueryType `json:"type,omitempty"` +} + +// NodesQueryType defines model for NodesQuery.Type. +type NodesQueryType string + +// PulseWaveQuery defines model for PulseWaveQuery. +type PulseWaveQuery struct { + OffCount *int64 `json:"offCount,omitempty"` + OffValue *float64 `json:"offValue,omitempty"` + OnCount *int64 `json:"onCount,omitempty"` + OnValue *float64 `json:"onValue,omitempty"` + TimeStep *int64 `json:"timeStep,omitempty"` +} + +// TODO: Should this live here given it's not used in the dataquery? +type Scenario struct { + Description *string `json:"description,omitempty"` + HideAliasField *bool `json:"hideAliasField,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + StringInput string `json:"stringInput"` +} + +// SimulationQuery defines model for SimulationQuery. +type SimulationQuery struct { + Config map[string]interface{} `json:"config,omitempty"` + Key struct { + Tick float64 `json:"tick"` + Type string `json:"type"` + Uid *string `json:"uid,omitempty"` + } `json:"key"` + Last *bool `json:"last,omitempty"` + Stream *bool `json:"stream,omitempty"` +} + +// StreamingQuery defines model for StreamingQuery. +type StreamingQuery struct { + Bands *int32 `json:"bands,omitempty"` + Noise int32 `json:"noise"` + Speed int32 `json:"speed"` + Spread int32 `json:"spread"` + Type StreamingQueryType `json:"type"` + Url *string `json:"url,omitempty"` +} + +// StreamingQueryType defines model for StreamingQuery.Type. +type StreamingQueryType string + +// TestDataDataQuery defines model for TestDataDataQuery. +type TestDataDataQuery struct { + Alias *string `json:"alias,omitempty"` + Channel *string `json:"channel,omitempty"` + CsvContent *string `json:"csvContent,omitempty"` + CsvFileName *string `json:"csvFileName,omitempty"` + CsvWave *[]struct { + Labels *string `json:"labels,omitempty"` + Name *string `json:"name,omitempty"` + TimeStep *int64 `json:"timeStep,omitempty"` + ValuesCSV *string `json:"valuesCSV,omitempty"` + } `json:"csvWave,omitempty"` + + // For mixed data sources the selected datasource is on the query level. + // For non mixed scenarios this is undefined. + // TODO find a better way to do this ^ that's friendly to schema + // TODO this shouldn't be unknown but DataSourceRef | null + Datasource *interface{} `json:"datasource,omitempty"` + ErrorType *ErrorType `json:"errorType,omitempty"` + + // true if query is disabled (ie should not be returned to the dashboard) + Hide *bool `json:"hide,omitempty"` + + // Unique, guid like, string used in explore mode + Key *string `json:"key,omitempty"` + Labels *string `json:"labels,omitempty"` + LevelColumn *bool `json:"levelColumn,omitempty"` + Lines *int64 `json:"lines,omitempty"` + Nodes *struct { + Count *int64 `json:"count,omitempty"` + Type *NodesType `json:"type,omitempty"` + } `json:"nodes,omitempty"` + Points *[][]interface{} `json:"points,omitempty"` + PulseWave *struct { + OffCount *int64 `json:"offCount,omitempty"` + OffValue *float64 `json:"offValue,omitempty"` + OnCount *int64 `json:"onCount,omitempty"` + OnValue *float64 `json:"onValue,omitempty"` + TimeStep *int64 `json:"timeStep,omitempty"` + } `json:"pulseWave,omitempty"` + + // Specify the query flavor + // TODO make this required and give it a default + QueryType *string `json:"queryType,omitempty"` + RawFrameContent *string `json:"rawFrameContent,omitempty"` + + // A - Z + RefId string `json:"refId"` + ScenarioId *ScenarioId `json:"scenarioId,omitempty"` + SeriesCount *int32 `json:"seriesCount,omitempty"` + Sim *struct { + Config map[string]interface{} `json:"config,omitempty"` + Key struct { + Tick float64 `json:"tick"` + Type string `json:"type"` + Uid *string `json:"uid,omitempty"` + } `json:"key"` + Last *bool `json:"last,omitempty"` + Stream *bool `json:"stream,omitempty"` + } `json:"sim,omitempty"` + SpanCount *int32 `json:"spanCount,omitempty"` + Stream *struct { + Bands *int32 `json:"bands,omitempty"` + Noise int32 `json:"noise"` + Speed int32 `json:"speed"` + Spread int32 `json:"spread"` + Type StreamType `json:"type"` + Url *string `json:"url,omitempty"` + } `json:"stream,omitempty"` + StringInput *string `json:"stringInput,omitempty"` + Usa *struct { + Fields *[]string `json:"fields,omitempty"` + Mode *string `json:"mode,omitempty"` + Period *string `json:"period,omitempty"` + States *[]string `json:"states,omitempty"` + } `json:"usa,omitempty"` +} + +// ErrorType defines model for TestDataDataQuery.ErrorType. +type ErrorType string + +// NodesType defines model for TestDataDataQuery.Nodes.Type. +type NodesType string + +// ScenarioId defines model for TestDataDataQuery.ScenarioId. +type ScenarioId string + +// StreamType defines model for TestDataDataQuery.Stream.Type. +type StreamType string + +// TestDataQueryType defines model for TestDataQueryType. +type TestDataQueryType string + +// USAQuery defines model for USAQuery. +type USAQuery struct { + Fields *[]string `json:"fields,omitempty"` + Mode *string `json:"mode,omitempty"` + Period *string `json:"period,omitempty"` + States *[]string `json:"states,omitempty"` +} diff --git a/pkg/tsdb/testdatasource/resource_handler.go b/pkg/tsdb/testdatasource/resource_handler.go index 06615f22919..4d6eba4bd53 100644 --- a/pkg/tsdb/testdatasource/resource_handler.go +++ b/pkg/tsdb/testdatasource/resource_handler.go @@ -10,8 +10,6 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" - - "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" ) func (s *Service) registerRoutes() *http.ServeMux { @@ -130,8 +128,6 @@ func createJSONHandler(logger log.Logger) http.Handler { } } - config := httpadapter.PluginConfigFromContext(req.Context()) - data := map[string]interface{}{ "message": "Hello world from test datasource!", "request": map[string]interface{}{ @@ -139,7 +135,6 @@ func createJSONHandler(logger log.Logger) http.Handler { "url": req.URL, "headers": req.Header, "body": reqData, - "config": config, }, } bytes, err := json.Marshal(&data) diff --git a/pkg/util/xorm/dialect_oracle.go b/pkg/util/xorm/dialect_oracle.go deleted file mode 100644 index d23ab1678af..00000000000 --- a/pkg/util/xorm/dialect_oracle.go +++ /dev/null @@ -1,902 +0,0 @@ -// Copyright 2015 The Xorm Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package xorm - -import ( - "errors" - "fmt" - "regexp" - "strconv" - "strings" - - "xorm.io/core" -) - -var ( - oracleReservedWords = map[string]bool{ - "ACCESS": true, - "ACCOUNT": true, - "ACTIVATE": true, - "ADD": true, - "ADMIN": true, - "ADVISE": true, - "AFTER": true, - "ALL": true, - "ALL_ROWS": true, - "ALLOCATE": true, - "ALTER": true, - "ANALYZE": true, - "AND": true, - "ANY": true, - "ARCHIVE": true, - "ARCHIVELOG": true, - "ARRAY": true, - "AS": true, - "ASC": true, - "AT": true, - "AUDIT": true, - "AUTHENTICATED": true, - "AUTHORIZATION": true, - "AUTOEXTEND": true, - "AUTOMATIC": true, - "BACKUP": true, - "BECOME": true, - "BEFORE": true, - "BEGIN": true, - "BETWEEN": true, - "BFILE": true, - "BITMAP": true, - "BLOB": true, - "BLOCK": true, - "BODY": true, - "BY": true, - "CACHE": true, - "CACHE_INSTANCES": true, - "CANCEL": true, - "CASCADE": true, - "CAST": true, - "CFILE": true, - "CHAINED": true, - "CHANGE": true, - "CHAR": true, - "CHAR_CS": true, - "CHARACTER": true, - "CHECK": true, - "CHECKPOINT": true, - "CHOOSE": true, - "CHUNK": true, - "CLEAR": true, - "CLOB": true, - "CLONE": true, - "CLOSE": true, - "CLOSE_CACHED_OPEN_CURSORS": true, - "CLUSTER": true, - "COALESCE": true, - "COLUMN": true, - "COLUMNS": true, - "COMMENT": true, - "COMMIT": true, - "COMMITTED": true, - "COMPATIBILITY": true, - "COMPILE": true, - "COMPLETE": true, - "COMPOSITE_LIMIT": true, - "COMPRESS": true, - "COMPUTE": true, - "CONNECT": true, - "CONNECT_TIME": true, - "CONSTRAINT": true, - "CONSTRAINTS": true, - "CONTENTS": true, - "CONTINUE": true, - "CONTROLFILE": true, - "CONVERT": true, - "COST": true, - "CPU_PER_CALL": true, - "CPU_PER_SESSION": true, - "CREATE": true, - "CURRENT": true, - "CURRENT_SCHEMA": true, - "CURREN_USER": true, - "CURSOR": true, - "CYCLE": true, - "DANGLING": true, - "DATABASE": true, - "DATAFILE": true, - "DATAFILES": true, - "DATAOBJNO": true, - "DATE": true, - "DBA": true, - "DBHIGH": true, - "DBLOW": true, - "DBMAC": true, - "DEALLOCATE": true, - "DEBUG": true, - "DEC": true, - "DECIMAL": true, - "DECLARE": true, - "DEFAULT": true, - "DEFERRABLE": true, - "DEFERRED": true, - "DEGREE": true, - "DELETE": true, - "DEREF": true, - "DESC": true, - "DIRECTORY": true, - "DISABLE": true, - "DISCONNECT": true, - "DISMOUNT": true, - "DISTINCT": true, - "DISTRIBUTED": true, - "DML": true, - "DOUBLE": true, - "DROP": true, - "DUMP": true, - "EACH": true, - "ELSE": true, - "ENABLE": true, - "END": true, - "ENFORCE": true, - "ENTRY": true, - "ESCAPE": true, - "EXCEPT": true, - "EXCEPTIONS": true, - "EXCHANGE": true, - "EXCLUDING": true, - "EXCLUSIVE": true, - "EXECUTE": true, - "EXISTS": true, - "EXPIRE": true, - "EXPLAIN": true, - "EXTENT": true, - "EXTENTS": true, - "EXTERNALLY": true, - "FAILED_LOGIN_ATTEMPTS": true, - "FALSE": true, - "FAST": true, - "FILE": true, - "FIRST_ROWS": true, - "FLAGGER": true, - "FLOAT": true, - "FLOB": true, - "FLUSH": true, - "FOR": true, - "FORCE": true, - "FOREIGN": true, - "FREELIST": true, - "FREELISTS": true, - "FROM": true, - "FULL": true, - "FUNCTION": true, - "GLOBAL": true, - "GLOBALLY": true, - "GLOBAL_NAME": true, - "GRANT": true, - "GROUP": true, - "GROUPS": true, - "HASH": true, - "HASHKEYS": true, - "HAVING": true, - "HEADER": true, - "HEAP": true, - "IDENTIFIED": true, - "IDGENERATORS": true, - "IDLE_TIME": true, - "IF": true, - "IMMEDIATE": true, - "IN": true, - "INCLUDING": true, - "INCREMENT": true, - "INDEX": true, - "INDEXED": true, - "INDEXES": true, - "INDICATOR": true, - "IND_PARTITION": true, - "INITIAL": true, - "INITIALLY": true, - "INITRANS": true, - "INSERT": true, - "INSTANCE": true, - "INSTANCES": true, - "INSTEAD": true, - "INT": true, - "INTEGER": true, - "INTERMEDIATE": true, - "INTERSECT": true, - "INTO": true, - "IS": true, - "ISOLATION": true, - "ISOLATION_LEVEL": true, - "KEEP": true, - "KEY": true, - "KILL": true, - "LABEL": true, - "LAYER": true, - "LESS": true, - "LEVEL": true, - "LIBRARY": true, - "LIKE": true, - "LIMIT": true, - "LINK": true, - "LIST": true, - "LOB": true, - "LOCAL": true, - "LOCK": true, - "LOCKED": true, - "LOG": true, - "LOGFILE": true, - "LOGGING": true, - "LOGICAL_READS_PER_CALL": true, - "LOGICAL_READS_PER_SESSION": true, - "LONG": true, - "MANAGE": true, - "MASTER": true, - "MAX": true, - "MAXARCHLOGS": true, - "MAXDATAFILES": true, - "MAXEXTENTS": true, - "MAXINSTANCES": true, - "MAXLOGFILES": true, - "MAXLOGHISTORY": true, - "MAXLOGMEMBERS": true, - "MAXSIZE": true, - "MAXTRANS": true, - "MAXVALUE": true, - "MIN": true, - "MEMBER": true, - "MINIMUM": true, - "MINEXTENTS": true, - "MINUS": true, - "MINVALUE": true, - "MLSLABEL": true, - "MLS_LABEL_FORMAT": true, - "MODE": true, - "MODIFY": true, - "MOUNT": true, - "MOVE": true, - "MTS_DISPATCHERS": true, - "MULTISET": true, - "NATIONAL": true, - "NCHAR": true, - "NCHAR_CS": true, - "NCLOB": true, - "NEEDED": true, - "NESTED": true, - "NETWORK": true, - "NEW": true, - "NEXT": true, - "NOARCHIVELOG": true, - "NOAUDIT": true, - "NOCACHE": true, - "NOCOMPRESS": true, - "NOCYCLE": true, - "NOFORCE": true, - "NOLOGGING": true, - "NOMAXVALUE": true, - "NOMINVALUE": true, - "NONE": true, - "NOORDER": true, - "NOOVERRIDE": true, - "NOPARALLEL": true, - "NOREVERSE": true, - "NORMAL": true, - "NOSORT": true, - "NOT": true, - "NOTHING": true, - "NOWAIT": true, - "NULL": true, - "NUMBER": true, - "NUMERIC": true, - "NVARCHAR2": true, - "OBJECT": true, - "OBJNO": true, - "OBJNO_REUSE": true, - "OF": true, - "OFF": true, - "OFFLINE": true, - "OID": true, - "OIDINDEX": true, - "OLD": true, - "ON": true, - "ONLINE": true, - "ONLY": true, - "OPCODE": true, - "OPEN": true, - "OPTIMAL": true, - "OPTIMIZER_GOAL": true, - "OPTION": true, - "OR": true, - "ORDER": true, - "ORGANIZATION": true, - "OSLABEL": true, - "OVERFLOW": true, - "OWN": true, - "PACKAGE": true, - "PARALLEL": true, - "PARTITION": true, - "PASSWORD": true, - "PASSWORD_GRACE_TIME": true, - "PASSWORD_LIFE_TIME": true, - "PASSWORD_LOCK_TIME": true, - "PASSWORD_REUSE_MAX": true, - "PASSWORD_REUSE_TIME": true, - "PASSWORD_VERIFY_FUNCTION": true, - "PCTFREE": true, - "PCTINCREASE": true, - "PCTTHRESHOLD": true, - "PCTUSED": true, - "PCTVERSION": true, - "PERCENT": true, - "PERMANENT": true, - "PLAN": true, - "PLSQL_DEBUG": true, - "POST_TRANSACTION": true, - "PRECISION": true, - "PRESERVE": true, - "PRIMARY": true, - "PRIOR": true, - "PRIVATE": true, - "PRIVATE_SGA": true, - "PRIVILEGE": true, - "PRIVILEGES": true, - "PROCEDURE": true, - "PROFILE": true, - "PUBLIC": true, - "PURGE": true, - "QUEUE": true, - "QUOTA": true, - "RANGE": true, - "RAW": true, - "RBA": true, - "READ": true, - "READUP": true, - "REAL": true, - "REBUILD": true, - "RECOVER": true, - "RECOVERABLE": true, - "RECOVERY": true, - "REF": true, - "REFERENCES": true, - "REFERENCING": true, - "REFRESH": true, - "RENAME": true, - "REPLACE": true, - "RESET": true, - "RESETLOGS": true, - "RESIZE": true, - "RESOURCE": true, - "RESTRICTED": true, - "RETURN": true, - "RETURNING": true, - "REUSE": true, - "REVERSE": true, - "REVOKE": true, - "ROLE": true, - "ROLES": true, - "ROLLBACK": true, - "ROW": true, - "ROWID": true, - "ROWNUM": true, - "ROWS": true, - "RULE": true, - "SAMPLE": true, - "SAVEPOINT": true, - "SB4": true, - "SCAN_INSTANCES": true, - "SCHEMA": true, - "SCN": true, - "SCOPE": true, - "SD_ALL": true, - "SD_INHIBIT": true, - "SD_SHOW": true, - "SEGMENT": true, - "SEG_BLOCK": true, - "SEG_FILE": true, - "SELECT": true, - "SEQUENCE": true, - "SERIALIZABLE": true, - "SESSION": true, - "SESSION_CACHED_CURSORS": true, - "SESSIONS_PER_USER": true, - "SET": true, - "SHARE": true, - "SHARED": true, - "SHARED_POOL": true, - "SHRINK": true, - "SIZE": true, - "SKIP": true, - "SKIP_UNUSABLE_INDEXES": true, - "SMALLINT": true, - "SNAPSHOT": true, - "SOME": true, - "SORT": true, - "SPECIFICATION": true, - "SPLIT": true, - "SQL_TRACE": true, - "STANDBY": true, - "START": true, - "STATEMENT_ID": true, - "STATISTICS": true, - "STOP": true, - "STORAGE": true, - "STORE": true, - "STRUCTURE": true, - "SUCCESSFUL": true, - "SWITCH": true, - "SYS_OP_ENFORCE_NOT_NULL$": true, - "SYS_OP_NTCIMG$": true, - "SYNONYM": true, - "SYSDATE": true, - "SYSDBA": true, - "SYSOPER": true, - "SYSTEM": true, - "TABLE": true, - "TABLES": true, - "TABLESPACE": true, - "TABLESPACE_NO": true, - "TABNO": true, - "TEMPORARY": true, - "THAN": true, - "THE": true, - "THEN": true, - "THREAD": true, - "TIMESTAMP": true, - "TIME": true, - "TO": true, - "TOPLEVEL": true, - "TRACE": true, - "TRACING": true, - "TRANSACTION": true, - "TRANSITIONAL": true, - "TRIGGER": true, - "TRIGGERS": true, - "TRUE": true, - "TRUNCATE": true, - "TX": true, - "TYPE": true, - "UB2": true, - "UBA": true, - "UID": true, - "UNARCHIVED": true, - "UNDO": true, - "UNION": true, - "UNIQUE": true, - "UNLIMITED": true, - "UNLOCK": true, - "UNRECOVERABLE": true, - "UNTIL": true, - "UNUSABLE": true, - "UNUSED": true, - "UPDATABLE": true, - "UPDATE": true, - "USAGE": true, - "USE": true, - "USER": true, - "USING": true, - "VALIDATE": true, - "VALIDATION": true, - "VALUE": true, - "VALUES": true, - "VARCHAR": true, - "VARCHAR2": true, - "VARYING": true, - "VIEW": true, - "WHEN": true, - "WHENEVER": true, - "WHERE": true, - "WITH": true, - "WITHOUT": true, - "WORK": true, - "WRITE": true, - "WRITEDOWN": true, - "WRITEUP": true, - "XID": true, - "YEAR": true, - "ZONE": true, - } -) - -type oracle struct { - core.Base -} - -func (db *oracle) Init(d *core.DB, uri *core.Uri, drivername, dataSourceName string) error { - return db.Base.Init(d, db, uri, drivername, dataSourceName) -} - -func (db *oracle) SqlType(c *core.Column) string { - var res string - switch t := c.SQLType.Name; t { - case core.Bit, core.TinyInt, core.SmallInt, core.MediumInt, core.Int, core.Integer, core.BigInt, core.Bool, core.Serial, core.BigSerial: - res = "NUMBER" - case core.Binary, core.VarBinary, core.Blob, core.TinyBlob, core.MediumBlob, core.LongBlob, core.Bytea: - return core.Blob - case core.Time, core.DateTime, core.TimeStamp: - res = core.TimeStamp - case core.TimeStampz: - res = "TIMESTAMP WITH TIME ZONE" - case core.Float, core.Double, core.Numeric, core.Decimal: - res = "NUMBER" - case core.Text, core.MediumText, core.LongText, core.Json: - res = "CLOB" - case core.Char, core.Varchar, core.TinyText: - res = "VARCHAR2" - default: - res = t - } - - hasLen1 := (c.Length > 0) - hasLen2 := (c.Length2 > 0) - - if hasLen2 { - res += "(" + strconv.Itoa(c.Length) + "," + strconv.Itoa(c.Length2) + ")" - } else if hasLen1 { - res += "(" + strconv.Itoa(c.Length) + ")" - } - return res -} - -func (db *oracle) AutoIncrStr() string { - return "AUTO_INCREMENT" -} - -func (db *oracle) SupportInsertMany() bool { - return true -} - -func (db *oracle) IsReserved(name string) bool { - _, ok := oracleReservedWords[name] - return ok -} - -func (db *oracle) Quote(name string) string { - return "[" + name + "]" -} - -func (db *oracle) SupportEngine() bool { - return false -} - -func (db *oracle) SupportCharset() bool { - return false -} - -func (db *oracle) SupportDropIfExists() bool { - return false -} - -func (db *oracle) IndexOnTable() bool { - return false -} - -func (db *oracle) DropTableSql(tableName string) string { - return fmt.Sprintf("DROP TABLE `%s`", tableName) -} - -func (db *oracle) CreateTableSql(table *core.Table, tableName, storeEngine, charset string) string { - var sql string - sql = "CREATE TABLE " - if tableName == "" { - tableName = table.Name - } - - sql += db.Quote(tableName) + " (" - - pkList := table.PrimaryKeys - - for _, colName := range table.ColumnsSeq() { - col := table.GetColumn(colName) - /*if col.IsPrimaryKey && len(pkList) == 1 { - sql += col.String(b.dialect) - } else {*/ - sql += col.StringNoPk(db) - // } - sql = strings.TrimSpace(sql) - sql += ", " - } - - if len(pkList) > 0 { - sql += "PRIMARY KEY ( " - sql += db.Quote(strings.Join(pkList, db.Quote(","))) - sql += " ), " - } - - sql = sql[:len(sql)-2] + ")" - if db.SupportEngine() && storeEngine != "" { - sql += " ENGINE=" + storeEngine - } - if db.SupportCharset() { - if len(charset) == 0 { - charset = db.URI().Charset - } - if len(charset) > 0 { - sql += " DEFAULT CHARSET " + charset - } - } - return sql -} - -func (db *oracle) IndexCheckSql(tableName, idxName string) (string, []interface{}) { - args := []interface{}{tableName, idxName} - return `SELECT INDEX_NAME FROM USER_INDEXES ` + - `WHERE TABLE_NAME = :1 AND INDEX_NAME = :2`, args -} - -func (db *oracle) TableCheckSql(tableName string) (string, []interface{}) { - args := []interface{}{tableName} - return `SELECT table_name FROM user_tables WHERE table_name = :1`, args -} - -func (db *oracle) MustDropTable(tableName string) error { - sql, args := db.TableCheckSql(tableName) - db.LogSQL(sql, args) - - rows, err := db.DB().Query(sql, args...) - if err != nil { - return err - } - defer rows.Close() - - if !rows.Next() { - return nil - } - - sql = "Drop Table \"" + tableName + "\"" - db.LogSQL(sql, args) - - _, err = db.DB().Exec(sql) - return err -} - -/*func (db *oracle) ColumnCheckSql(tableName, colName string) (string, []interface{}) { - args := []interface{}{strings.ToUpper(tableName), strings.ToUpper(colName)} - return "SELECT column_name FROM USER_TAB_COLUMNS WHERE table_name = ?" + - " AND column_name = ?", args -}*/ - -func (db *oracle) IsColumnExist(tableName, colName string) (bool, error) { - args := []interface{}{tableName, colName} - query := "SELECT column_name FROM USER_TAB_COLUMNS WHERE table_name = :1" + - " AND column_name = :2" - db.LogSQL(query, args) - - rows, err := db.DB().Query(query, args...) - if err != nil { - return false, err - } - defer rows.Close() - - if rows.Next() { - return true, nil - } - return false, nil -} - -func (db *oracle) GetColumns(tableName string) ([]string, map[string]*core.Column, error) { - args := []interface{}{tableName} - s := "SELECT column_name,data_default,data_type,data_length,data_precision,data_scale," + - "nullable FROM USER_TAB_COLUMNS WHERE table_name = :1" - db.LogSQL(s, args) - - rows, err := db.DB().Query(s, args...) - if err != nil { - return nil, nil, err - } - defer rows.Close() - - cols := make(map[string]*core.Column) - colSeq := make([]string, 0) - for rows.Next() { - col := new(core.Column) - col.Indexes = make(map[string]int) - - var colName, colDefault, nullable, dataType, dataPrecision, dataScale *string - var dataLen int - - err = rows.Scan(&colName, &colDefault, &dataType, &dataLen, &dataPrecision, - &dataScale, &nullable) - if err != nil { - return nil, nil, err - } - - col.Name = strings.Trim(*colName, `" `) - if colDefault != nil { - col.Default = *colDefault - col.DefaultIsEmpty = false - } - - if *nullable == "Y" { - col.Nullable = true - } else { - col.Nullable = false - } - - var ignore bool - - var dt string - var len1, len2 int - dts := strings.Split(*dataType, "(") - dt = dts[0] - if len(dts) > 1 { - lens := strings.Split(dts[1][:len(dts[1])-1], ",") - if len(lens) > 1 { - len1, _ = strconv.Atoi(lens[0]) - len2, _ = strconv.Atoi(lens[1]) - } else { - len1, _ = strconv.Atoi(lens[0]) - } - } - - switch dt { - case "VARCHAR2": - col.SQLType = core.SQLType{Name: core.Varchar, DefaultLength: len1, DefaultLength2: len2} - case "NVARCHAR2": - col.SQLType = core.SQLType{Name: core.NVarchar, DefaultLength: len1, DefaultLength2: len2} - case "TIMESTAMP WITH TIME ZONE": - col.SQLType = core.SQLType{Name: core.TimeStampz, DefaultLength: 0, DefaultLength2: 0} - case "NUMBER": - col.SQLType = core.SQLType{Name: core.Double, DefaultLength: len1, DefaultLength2: len2} - case "LONG", "LONG RAW": - col.SQLType = core.SQLType{Name: core.Text, DefaultLength: 0, DefaultLength2: 0} - case "RAW": - col.SQLType = core.SQLType{Name: core.Binary, DefaultLength: 0, DefaultLength2: 0} - case "ROWID": - col.SQLType = core.SQLType{Name: core.Varchar, DefaultLength: 18, DefaultLength2: 0} - case "AQ$_SUBSCRIBERS": - ignore = true - default: - col.SQLType = core.SQLType{Name: strings.ToUpper(dt), DefaultLength: len1, DefaultLength2: len2} - } - - if ignore { - continue - } - - if _, ok := core.SqlTypes[col.SQLType.Name]; !ok { - return nil, nil, fmt.Errorf("unknown colType %v %v", *dataType, col.SQLType) - } - - col.Length = dataLen - - if col.SQLType.IsText() || col.SQLType.IsTime() { - if !col.DefaultIsEmpty { - col.Default = "'" + col.Default + "'" - } - } - cols[col.Name] = col - colSeq = append(colSeq, col.Name) - } - - return colSeq, cols, nil -} - -func (db *oracle) GetTables() ([]*core.Table, error) { - args := []interface{}{} - s := "SELECT table_name FROM user_tables" - db.LogSQL(s, args) - - rows, err := db.DB().Query(s, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - tables := make([]*core.Table, 0) - for rows.Next() { - table := core.NewEmptyTable() - err = rows.Scan(&table.Name) - if err != nil { - return nil, err - } - - tables = append(tables, table) - } - return tables, nil -} - -func (db *oracle) GetIndexes(tableName string) (map[string]*core.Index, error) { - args := []interface{}{tableName} - s := "SELECT t.column_name,i.uniqueness,i.index_name FROM user_ind_columns t,user_indexes i " + - "WHERE t.index_name = i.index_name and t.table_name = i.table_name and t.table_name =:1" - db.LogSQL(s, args) - - rows, err := db.DB().Query(s, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - indexes := make(map[string]*core.Index, 0) - for rows.Next() { - var indexType int - var indexName, colName, uniqueness string - - err = rows.Scan(&colName, &uniqueness, &indexName) - if err != nil { - return nil, err - } - - indexName = strings.Trim(indexName, `" `) - - var isRegular bool - if strings.HasPrefix(indexName, "IDX_"+tableName) || strings.HasPrefix(indexName, "UQE_"+tableName) { - indexName = indexName[5+len(tableName):] - isRegular = true - } - - if uniqueness == "UNIQUE" { - indexType = core.UniqueType - } else { - indexType = core.IndexType - } - - var index *core.Index - var ok bool - if index, ok = indexes[indexName]; !ok { - index = new(core.Index) - index.Type = indexType - index.Name = indexName - index.IsRegular = isRegular - indexes[indexName] = index - } - index.AddColumn(colName) - } - return indexes, nil -} - -func (db *oracle) Filters() []core.Filter { - return []core.Filter{&core.QuoteFilter{}, &core.SeqFilter{Prefix: ":", Start: 1}, &core.IdFilter{}} -} - -type goracleDriver struct { -} - -func (cfg *goracleDriver) Parse(driverName, dataSourceName string) (*core.Uri, error) { - db := &core.Uri{DbType: core.ORACLE} - dsnPattern := regexp.MustCompile( - `^(?:(?P.*?)(?::(?P.*))?@)?` + // [user[:password]@] - `(?:(?P[^\(]*)(?:\((?P[^\)]*)\))?)?` + // [net[(addr)]] - `\/(?P.*?)` + // /dbname - `(?:\?(?P[^\?]*))?$`) // [?param1=value1¶mN=valueN] - matches := dsnPattern.FindStringSubmatch(dataSourceName) - // tlsConfigRegister := make(map[string]*tls.Config) - names := dsnPattern.SubexpNames() - - for i, match := range matches { - switch names[i] { - case "dbname": - db.DbName = match - } - } - if db.DbName == "" { - return nil, errors.New("dbname is empty") - } - return db, nil -} - -type oci8Driver struct { -} - -// dataSourceName=user/password@ipv4:port/dbname -// dataSourceName=user/password@[ipv6]:port/dbname -func (p *oci8Driver) Parse(driverName, dataSourceName string) (*core.Uri, error) { - db := &core.Uri{DbType: core.ORACLE} - dsnPattern := regexp.MustCompile( - `^(?P.*)\/(?P.*)@` + // user:password@ - `(?P.*)` + // ip:port - `\/(?P.*)`) // dbname - matches := dsnPattern.FindStringSubmatch(dataSourceName) - names := dsnPattern.SubexpNames() - for i, match := range matches { - switch names[i] { - case "dbname": - db.DbName = match - } - } - if db.DbName == "" { - return nil, errors.New("dbname is empty") - } - return db, nil -} diff --git a/pkg/util/xorm/engine.go b/pkg/util/xorm/engine.go index 3c1267ae194..47608749d0d 100644 --- a/pkg/util/xorm/engine.go +++ b/pkg/util/xorm/engine.go @@ -46,13 +46,6 @@ type Engine struct { defaultContext context.Context } -// BufferSize sets buffer size for iterate -func (engine *Engine) BufferSize(size int) *Session { - session := engine.NewSession() - session.isAutoClose = true - return session.BufferSize(size) -} - // CondDeleted returns the conditions whether a record is soft deleted. func (engine *Engine) CondDeleted(col *core.Column) builder.Cond { var cond = builder.NewCond() @@ -238,14 +231,6 @@ func (engine *Engine) SetMaxIdleConns(conns int) { engine.db.SetMaxIdleConns(conns) } -// NoCache If you has set default cacher, and you want temporilly stop use cache, -// you can use NoCache() -func (engine *Engine) NoCache() *Session { - session := engine.NewSession() - session.isAutoClose = true - return session.NoCache() -} - // NewDB provides an interface to operate database directly func (engine *Engine) NewDB() (*core.DB, error) { return core.OpenDialect(engine.dialect) @@ -280,17 +265,6 @@ func (engine *Engine) Ping() error { return session.Ping() } -// logSQL save sql -func (engine *Engine) logSQL(sqlStr string, sqlArgs ...interface{}) { - if engine.showSQL && !engine.showExecTime { - if len(sqlArgs) > 0 { - engine.logger.Infof("[SQL] %v %#v", sqlStr, sqlArgs) - } else { - engine.logger.Infof("[SQL] %v", sqlStr) - } - } -} - // Sql provides raw sql input parameter. When you have a complex SQL statement // and cannot use Where, Id, In and etc. Methods to describe, you can use SQL. // @@ -311,22 +285,6 @@ func (engine *Engine) SQL(query interface{}, args ...interface{}) *Session { return session.SQL(query, args...) } -// NoAutoTime Default if your struct has "created" or "updated" filed tag, the fields -// will automatically be filled with current time when Insert or Update -// invoked. Call NoAutoTime if you dont' want to fill automatically. -func (engine *Engine) NoAutoTime() *Session { - session := engine.NewSession() - session.isAutoClose = true - return session.NoAutoTime() -} - -// NoAutoCondition disable auto generate Where condition from bean or not -func (engine *Engine) NoAutoCondition(no ...bool) *Session { - session := engine.NewSession() - session.isAutoClose = true - return session.NoAutoCondition(no...) -} - func (engine *Engine) loadTableInfo(table *core.Table) error { colSeq, cols, err := engine.dialect.GetColumns(table.Name) if err != nil { diff --git a/pkg/util/xorm/xorm.go b/pkg/util/xorm/xorm.go index c3f60c56d66..b3178773518 100644 --- a/pkg/util/xorm/xorm.go +++ b/pkg/util/xorm/xorm.go @@ -35,8 +35,6 @@ func regDrvsNDialects() bool { "postgres": {"postgres", func() core.Driver { return &pqDriver{} }, func() core.Dialect { return &postgres{} }}, "pgx": {"postgres", func() core.Driver { return &pqDriverPgx{} }, func() core.Dialect { return &postgres{} }}, "sqlite3": {"sqlite3", func() core.Driver { return &sqlite3Driver{} }, func() core.Dialect { return &sqlite3{} }}, - "oci8": {"oracle", func() core.Driver { return &oci8Driver{} }, func() core.Dialect { return &oracle{} }}, - "goracle": {"oracle", func() core.Driver { return &goracleDriver{} }, func() core.Dialect { return &oracle{} }}, } for driverName, v := range providedDrvsNDialects { diff --git a/pkg/web/webtest/webtest.go b/pkg/web/webtest/webtest.go index d76f6b56b5d..d29764e2e45 100644 --- a/pkg/web/webtest/webtest.go +++ b/pkg/web/webtest/webtest.go @@ -10,13 +10,13 @@ import ( "github.com/google/uuid" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" ) -var requests = map[string]*models.ReqContext{} +var requests = map[string]*contextmodel.ReqContext{} type Server struct { t testing.TB @@ -30,7 +30,7 @@ func NewServer(t testing.TB, routeRegister routing.RouteRegister) *Server { t.Helper() m := web.New() - initCtx := &models.ReqContext{} + initCtx := &contextmodel.ReqContext{} m.Use(func(c *web.Context) { initCtx.Context = c initCtx.Logger = log.New("api-test") @@ -104,20 +104,20 @@ func requestIdentifierFromRequest(req *http.Request) string { return req.Header.Get("X-GRAFANA-WEB-TEST-ID") } -func RequestWithWebContext(req *http.Request, c *models.ReqContext) *http.Request { +func RequestWithWebContext(req *http.Request, c *contextmodel.ReqContext) *http.Request { reqID := requestIdentifierFromRequest(req) requests[reqID] = c return req } func RequestWithSignedInUser(req *http.Request, user *user.SignedInUser) *http.Request { - return RequestWithWebContext(req, &models.ReqContext{ + return RequestWithWebContext(req, &contextmodel.ReqContext{ SignedInUser: user, IsSignedIn: true, }) } -func requestContextFromRequest(req *http.Request) *models.ReqContext { +func requestContextFromRequest(req *http.Request) *contextmodel.ReqContext { reqID := requestIdentifierFromRequest(req) val, exists := requests[reqID] if !exists { @@ -130,7 +130,7 @@ func requestContextFromRequest(req *http.Request) *models.ReqContext { func requestContextMiddleware() web.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c := ctxkey.Get(r.Context()).(*models.ReqContext) + c := ctxkey.Get(r.Context()).(*contextmodel.ReqContext) ctx := requestContextFromRequest(r) if ctx != nil { diff --git a/pkg/web/webtest/webtest_test.go b/pkg/web/webtest/webtest_test.go index f184f0e6bc5..e250605cada 100644 --- a/pkg/web/webtest/webtest_test.go +++ b/pkg/web/webtest/webtest_test.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/models" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/require" ) @@ -17,7 +17,7 @@ import ( func TestServer(t *testing.T) { routeRegister := routing.NewRouteRegister() var actualRequest *http.Request - routeRegister.Post("/api", routing.Wrap(func(c *models.ReqContext) response.Response { + routeRegister.Post("/api", routing.Wrap(func(c *contextmodel.ReqContext) response.Response { actualRequest = c.Req return response.JSON(http.StatusOK, c.SignedInUser) })) @@ -68,7 +68,7 @@ func verifyRequest(t *testing.T, s *Server, req *http.Request, expectedBody stri require.NotEmpty(t, requestIdentifierFromRequest(req)) - req = RequestWithWebContext(req, &models.ReqContext{ + req = RequestWithWebContext(req, &contextmodel.ReqContext{ IsSignedIn: true, }) require.NotNil(t, req) @@ -79,7 +79,7 @@ func verifyRequest(t *testing.T, s *Server, req *http.Request, expectedBody stri func TestServerClient(t *testing.T) { routeRegister := routing.NewRouteRegister() - routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response { + routeRegister.Get("/test", routing.Wrap(func(c *contextmodel.ReqContext) response.Response { return response.JSON(http.StatusOK, c.SignedInUser) })) @@ -87,7 +87,7 @@ func TestServerClient(t *testing.T) { t.Run("Making a request with user 1 should return user 1 as signed in user", func(t *testing.T) { req := s.NewRequest(http.MethodGet, "/test", nil) - req = RequestWithWebContext(req, &models.ReqContext{ + req = RequestWithWebContext(req, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ UserID: 1, }, @@ -109,7 +109,7 @@ func TestServerClient(t *testing.T) { t.Run("Making a request with user 2 should return user 2 as signed in user", func(t *testing.T) { req := s.NewRequest(http.MethodGet, "/test", nil) - req = RequestWithWebContext(req, &models.ReqContext{ + req = RequestWithWebContext(req, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ UserID: 2, }, diff --git a/public/api-merged.json b/public/api-merged.json index 56fd778555b..2505ee36660 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -2496,6 +2496,35 @@ } } }, + "/api/v1/provisioning/alert-rules/export": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Export all alert rules in provisioning file format.", + "operationId": "RouteGetAlertRulesExport", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/alert-rules/{UID}": { "get": { "tags": [ @@ -2591,6 +2620,47 @@ } } }, + "/api/v1/provisioning/alert-rules/{UID}/export": { + "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "tags": [ + "provisioning" + ], + "summary": "Export an alert rule in provisioning file format.", + "operationId": "RouteGetAlertRuleExport", + "parameters": [ + { + "type": "string", + "description": "Alert rule UID", + "name": "UID", + "in": "path", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/contact-points": { "get": { "tags": [ @@ -2794,6 +2864,52 @@ } } }, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { + "get": { + "produces": [ + "application/json", + "application/yaml", + "text/yaml" + ], + "tags": [ + "provisioning" + ], + "summary": "Export an alert rule group in provisioning file format.", + "operationId": "RouteGetAlertRuleGroupExport", + "parameters": [ + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "404": { + "description": " Not found." + } + } + } + }, "/api/v1/provisioning/mute-timings": { "get": { "tags": [ @@ -11044,6 +11160,28 @@ } } }, + "AlertQueryExport": { + "type": "object", + "title": "AlertQueryExport is the provisioned export of models.AlertQuery.", + "properties": { + "datasourceUid": { + "type": "string" + }, + "model": { + "type": "object", + "additionalProperties": false + }, + "queryType": { + "type": "string" + }, + "refId": { + "type": "string" + }, + "relativeTimeRange": { + "$ref": "#/definitions/RelativeTimeRange" + } + } + }, "AlertResponse": { "type": "object", "required": [ @@ -11064,6 +11202,65 @@ } } }, + "AlertRuleExport": { + "type": "object", + "title": "AlertRuleExport is the provisioned file export of models.AlertRule.", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "condition": { + "type": "string" + }, + "dasboardUid": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertQueryExport" + } + }, + "execErrState": { + "type": "string", + "enum": [ + "Alerting", + "Error", + "OK" + ] + }, + "for": { + "$ref": "#/definitions/Duration" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "noDataState": { + "type": "string", + "enum": [ + "Alerting", + "NoData", + "OK" + ] + }, + "panelId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, "AlertRuleGroup": { "type": "object", "properties": { @@ -11085,6 +11282,31 @@ } } }, + "AlertRuleGroupExport": { + "type": "object", + "title": "AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1.", + "properties": { + "folder": { + "type": "string" + }, + "interval": { + "$ref": "#/definitions/Duration" + }, + "name": { + "type": "string" + }, + "orgId": { + "type": "integer", + "format": "int64" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertRuleExport" + } + } + } + }, "AlertRuleGroupMetadata": { "type": "object", "properties": { @@ -11174,6 +11396,22 @@ } } }, + "AlertingFileExport": { + "type": "object", + "title": "AlertingFileExport is the full provisioned file export.", + "properties": { + "apiVersion": { + "type": "integer", + "format": "int64" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/AlertRuleGroupExport" + } + } + } + }, "AlertingRule": { "description": "adapted from cortex", "type": "object", @@ -12740,6 +12978,27 @@ } } }, + "DiscordConfig": { + "type": "object", + "title": "DiscordConfig configures notifications via Discord.", + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + } + } + }, "DiscoveryBase": { "type": "object", "required": [ @@ -13369,6 +13628,12 @@ "GettableApiReceiver": { "type": "object", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -13427,6 +13692,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -13492,7 +13763,7 @@ } }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -13724,9 +13995,15 @@ "victorops_api_key": { "$ref": "#/definitions/Secret" }, + "victorops_api_key_file": { + "type": "string" + }, "victorops_api_url": { "$ref": "#/definitions/URL" }, + "webex_api_url": { + "$ref": "#/definitions/URL" + }, "wechat_api_corp_id": { "type": "string" }, @@ -13766,6 +14043,9 @@ "oauth2": { "$ref": "#/definitions/OAuth2" }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -13774,6 +14054,15 @@ } } }, + "Header": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/Secret" + } + } + }, "Hit": { "type": "object", "properties": { @@ -14928,15 +15217,24 @@ "routing_key": { "$ref": "#/definitions/Secret" }, + "routing_key_file": { + "type": "string" + }, "send_resolved": { "type": "boolean" }, "service_key": { "$ref": "#/definitions/Secret" }, + "service_key_file": { + "type": "string" + }, "severity": { "type": "string" }, + "source": { + "type": "string" + }, "url": { "$ref": "#/definitions/URL" } @@ -15328,6 +15626,12 @@ "PostableApiReceiver": { "type": "object", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -15386,6 +15690,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -15448,7 +15758,7 @@ } }, "settings": { - "$ref": "#/definitions/Json" + "$ref": "#/definitions/RawMessage" }, "type": { "type": "string" @@ -15778,6 +16088,9 @@ "token": { "$ref": "#/definitions/Secret" }, + "token_file": { + "type": "string" + }, "url": { "type": "string" }, @@ -15786,6 +16099,9 @@ }, "user_key": { "$ref": "#/definitions/Secret" + }, + "user_key_file": { + "type": "string" } } }, @@ -16030,10 +16346,19 @@ } } }, + "RawMessage": { + "type": "object" + }, "Receiver": { "type": "object", "title": "Receiver configuration provides configuration on how to contact a receiver.", "properties": { + "discord_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/DiscordConfig" + } + }, "email_configs": { "type": "array", "items": { @@ -16086,6 +16411,12 @@ "$ref": "#/definitions/VictorOpsConfig" } }, + "webex_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/WebexConfig" + } + }, "webhook_configs": { "type": "array", "items": { @@ -17156,6 +17487,9 @@ "description": "The client key file for the targets.", "type": "string" }, + "max_version": { + "$ref": "#/definitions/TLSVersion" + }, "min_version": { "$ref": "#/definitions/TLSVersion" }, @@ -18436,7 +18770,7 @@ "$ref": "#/definitions/Secret" }, "api_key_file": { - "$ref": "#/definitions/Secret" + "type": "string" }, "api_url": { "$ref": "#/definitions/URL" @@ -18474,6 +18808,27 @@ "type": "string", "title": "VisType is used to indicate how the data should be visualized in explore." }, + "WebexConfig": { + "type": "object", + "title": "WebexConfig configures notifications via Webex.", + "properties": { + "api_url": { + "$ref": "#/definitions/URL" + }, + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "message": { + "type": "string" + }, + "room_id": { + "type": "string" + }, + "send_resolved": { + "type": "boolean" + } + } + }, "WebhookConfig": { "type": "object", "title": "WebhookConfig configures notifications via a generic webhook.", @@ -18551,7 +18906,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -18735,6 +19089,7 @@ } }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -18938,6 +19293,7 @@ } }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts index a4fb82ec255..2d64500d54b 100644 --- a/public/app/angular/AngularApp.ts +++ b/public/app/angular/AngularApp.ts @@ -43,12 +43,14 @@ export class AngularApp { '$filterProvider', '$httpProvider', '$provide', + '$sceDelegateProvider', ( $controllerProvider: angular.IControllerProvider, $compileProvider: angular.ICompileProvider, $filterProvider: angular.IFilterProvider, $httpProvider: angular.IHttpProvider, - $provide: angular.auto.IProvideService + $provide: angular.auto.IProvideService, + $sceDelegateProvider: angular.ISCEDelegateProvider ) => { if (config.buildInfo.env !== 'development') { $compileProvider.debugInfoEnabled(false); @@ -56,6 +58,10 @@ export class AngularApp { $httpProvider.useApplyAsync(true); + if (Boolean(config.pluginsCDNBaseURL)) { + $sceDelegateProvider.trustedResourceUrlList(['self', `${config.pluginsCDNBaseURL}/**`]); + } + this.registerFunctions.controller = $controllerProvider.register; this.registerFunctions.directive = $compileProvider.directive; this.registerFunctions.factory = $provide.factory; diff --git a/public/app/angular/components/plugin_component.test.ts b/public/app/angular/components/plugin_component.test.ts new file mode 100644 index 00000000000..e5205751fc6 --- /dev/null +++ b/public/app/angular/components/plugin_component.test.ts @@ -0,0 +1,18 @@ +import { config } from '@grafana/runtime'; + +import { relativeTemplateUrlToCDN } from './plugin_component'; + +describe('Plugin Component', () => { + describe('relativeTemplateUrlToCDN()', () => { + it('should create a proper path', () => { + config.pluginsCDNBaseURL = 'http://my-host.com'; + + const templateUrl = 'partials/module.html'; + const baseUrl = 'plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel'; + const expectedUrl = + 'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/partials/module.html'; + + expect(relativeTemplateUrlToCDN(templateUrl, baseUrl)).toBe(expectedUrl); + }); + }); +}); diff --git a/public/app/angular/components/plugin_component.ts b/public/app/angular/components/plugin_component.ts index f8271c41cec..a22b122c31c 100644 --- a/public/app/angular/components/plugin_component.ts +++ b/public/app/angular/components/plugin_component.ts @@ -8,6 +8,20 @@ import config from 'app/core/config'; import { importPanelPlugin } from '../../features/plugins/importPanelPlugin'; import { importDataSourcePlugin, importAppPlugin } from '../../features/plugins/plugin_loader'; +export function relativeTemplateUrlToCDN(templateUrl: string, baseUrl: string) { + if (!templateUrl) { + return undefined; + } + + // the templateUrl may have already been updated with the hostname + if (templateUrl.startsWith(config.pluginsCDNBaseURL)) { + return templateUrl; + } + + // use the 'plugin-cdn' key to load via cdn + return `${baseUrl.replace('plugin-cdn/', `${config.pluginsCDNBaseURL}/`)}/${templateUrl}`; +} + coreModule.directive('pluginComponent', ['$compile', '$http', '$templateCache', '$location', pluginDirectiveLoader]); function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $location: ILocationService) { @@ -31,12 +45,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $ if (templateUrl.indexOf('public') === 0) { return templateUrl; } + return baseUrl + '/' + templateUrl; } function getPluginComponentDirective(options: any) { - // handle relative template urls for plugin templates - options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl); + if (options.baseUrl.includes('plugin-cdn')) { + options.Component.templateUrl = relativeTemplateUrlToCDN(options.Component.templateUrl, options.baseUrl); + } else { + // handle relative template urls for plugin templates + options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl); + } return () => { return { @@ -86,13 +105,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $ } if (panelInfo) { - PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl); + if (panelInfo.baseUrl.includes('plugin-cdn')) { + PanelCtrl.templateUrl = relativeTemplateUrlToCDN(PanelCtrl.templateUrl, panelInfo.baseUrl); + } else { + PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl); + } } PanelCtrl.templatePromise = getTemplate(PanelCtrl).then((template: any) => { PanelCtrl.templateUrl = null; PanelCtrl.template = `${template}`; - return componentInfo; + return { ...componentInfo, baseUrl: panelInfo.baseUrl }; }); return PanelCtrl.templatePromise; diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx index 3aba09ac311..568ec4af0d8 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx @@ -4,10 +4,18 @@ import React from 'react'; import { Provider } from 'react-redux'; import { NavModelItem, NavSection } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; import { QuickAdd } from './QuickAdd'; +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + const setup = () => { const navBarTree: NavModelItem[] = [ { @@ -16,7 +24,7 @@ const setup = () => { id: 'section1', url: 'section1', children: [ - { text: 'New child 1', id: 'child1', url: 'section1/child1', isCreateAction: true }, + { text: 'New child 1', id: 'child1', url: '#', isCreateAction: true }, { text: 'Child2', id: 'child2', url: 'section1/child2' }, ], }, @@ -50,4 +58,15 @@ describe('QuickAdd', () => { expect(screen.getByRole('link', { name: 'New child 1' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'New child 3' })).toBeInTheDocument(); }); + + it('reports interaction when a menu item is clicked', async () => { + setup(); + await userEvent.click(screen.getByRole('button', { name: 'New' })); + await userEvent.click(screen.getByRole('link', { name: 'New child 1' })); + + expect(reportInteraction).toHaveBeenCalledWith('grafana_menu_item_clicked', { + url: '#', + from: 'quickadd', + }); + }); }); diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx index 0ed2337c7ff..ac38c048f0b 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Menu, Dropdown, useStyles2, useTheme2, ToolbarButton } from '@grafana/ui'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { useSelector } from 'app/types'; @@ -32,7 +33,12 @@ export const QuickAdd = ({}: Props) => { return ( {createActions.map((createAction, index) => ( - + reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' })} + /> ))} ); diff --git a/public/app/core/components/AppChrome/TopSearchBar.tsx b/public/app/core/components/AppChrome/TopSearchBar.tsx index bde15eea252..726171f4ddc 100644 --- a/public/app/core/components/AppChrome/TopSearchBar.tsx +++ b/public/app/core/components/AppChrome/TopSearchBar.tsx @@ -79,7 +79,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ justifyContent: 'space-between', [theme.breakpoints.up('sm')]: { - gridTemplateColumns: '2fr minmax(200px, 1fr) 2fr', // search should not be smaller than 200px + gridTemplateColumns: '1.5fr minmax(240px, 1fr) 1.5fr', // search should not be smaller than 240px display: 'grid', justifyContent: 'flex-start', diff --git a/public/app/core/components/AppChrome/TopSearchBarCommandPaletteTrigger.tsx b/public/app/core/components/AppChrome/TopSearchBarCommandPaletteTrigger.tsx index db5b77eecff..08a5c701c51 100644 --- a/public/app/core/components/AppChrome/TopSearchBarCommandPaletteTrigger.tsx +++ b/public/app/core/components/AppChrome/TopSearchBarCommandPaletteTrigger.tsx @@ -1,12 +1,13 @@ import { css } from '@emotion/css'; import { useKBar, VisualState } from 'kbar'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { getInputStyles, Icon, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui'; import { focusCss } from '@grafana/ui/src/themes/mixins'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { t } from 'app/core/internationalization'; +import { getModKey } from 'app/core/utils/browser'; export function TopSearchBarCommandPaletteTrigger() { const theme = useTheme2(); @@ -35,7 +36,7 @@ export function TopSearchBarCommandPaletteTrigger() { ); @@ -44,8 +45,13 @@ export function TopSearchBarCommandPaletteTrigger() { return ; } -function PretendTextInput({ onClick }: { onClick: () => void }) { +interface PretendTextInputProps { + onClick: () => void; +} + +function PretendTextInput({ onClick }: PretendTextInputProps) { const styles = useStyles2(getStyles); + const modKey = useMemo(() => getModKey(), []); // We want the desktop command palette trigger to look like a search box, // but it actually behaves like a button - you active it and it performs an @@ -59,8 +65,13 @@ function PretendTextInput({ onClick }: { onClick: () => void }) {
+ +
+ + {modKey}+k +
); @@ -73,6 +84,16 @@ const getStyles = (theme: GrafanaTheme2) => { wrapper: baseStyles.wrapper, inputWrapper: baseStyles.inputWrapper, prefix: baseStyles.prefix, + suffix: css([ + baseStyles.suffix, + { + display: 'flex', + gap: theme.spacing(0.5), + }, + ]), + shortcut: css({ + fontSize: theme.typography.bodySmall.fontSize, + }), fakeInput: css([ baseStyles.input, { diff --git a/public/app/core/components/PageNew/SectionNavItem.test.tsx b/public/app/core/components/PageNew/SectionNavItem.test.tsx new file mode 100644 index 00000000000..90d92e75462 --- /dev/null +++ b/public/app/core/components/PageNew/SectionNavItem.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { NavModelItem } from '@grafana/data'; + +import { SectionNavItem } from './SectionNavItem'; + +describe('SectionNavItem', () => { + it('should only show the img for a section root if both img and icon are present', () => { + const item: NavModelItem = { + text: 'Test', + icon: 'k6', + img: 'img', + children: [ + { + text: 'Child', + }, + ], + }; + + render(); + expect(screen.getByTestId('section-image')).toBeInTheDocument(); + expect(screen.queryByTestId('section-icon')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/PageNew/SectionNavItem.tsx b/public/app/core/components/PageNew/SectionNavItem.tsx index 148a3b386f1..9a85a66ee76 100644 --- a/public/app/core/components/PageNew/SectionNavItem.tsx +++ b/public/app/core/components/PageNew/SectionNavItem.tsx @@ -28,6 +28,14 @@ export function SectionNavItem({ item, isSectionRoot = false }: Props) { [styles.noRootMargin]: noRootMargin, }); + let icon: React.ReactNode | null = null; + + if (item.img) { + icon = ; + } else if (item.icon) { + icon = ; + } + return ( <> - {isSectionRoot && item.icon && } - {isSectionRoot && item.img && {`logo} + {isSectionRoot && icon} {getNavTitle(item.id) ?? item.text} {item.tabSuffix && } diff --git a/public/app/core/components/SVG/SanitizedSVG.tsx b/public/app/core/components/SVG/SanitizedSVG.tsx new file mode 100644 index 00000000000..9a2628cd63d --- /dev/null +++ b/public/app/core/components/SVG/SanitizedSVG.tsx @@ -0,0 +1,18 @@ +import * as DOMPurify from 'dompurify'; +import React from 'react'; +import SVG, { Props } from 'react-inlinesvg'; + +export const SanitizedSVG = (props: Props) => { + return ; +}; + +let cache = new Map(); + +function getCleanSVG(code: string): string { + let clean = cache.get(code); + if (!clean) { + clean = DOMPurify.sanitize(code, { USE_PROFILES: { svg: true, svgFilters: true } }); + cache.set(code, clean); + } + return clean; +} diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index e6b4fc69fcc..a1ea4c4ff0d 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -5,7 +5,7 @@ import TestProvider from 'test/helpers/TestProvider'; import { assertInstanceOf } from 'test/helpers/asserts'; import { getSelectParent, selectOptionInTest } from 'test/helpers/selectOptionInTest'; -import { UserPreferencesDTO } from 'app/types'; +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; import SharedPreferences from './SharedPreferences'; diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 5a6486e250d..73b7ee09290 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -4,6 +4,7 @@ import React, { PureComponent } from 'react'; import { FeatureState, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, reportInteraction } from '@grafana/runtime'; +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; import { Button, Field, @@ -21,7 +22,6 @@ import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { t, Trans } from 'app/core/internationalization'; import { LANGUAGES } from 'app/core/internationalization/constants'; import { PreferencesService } from 'app/core/services/PreferencesService'; -import { UserPreferencesDTO } from 'app/types'; export interface Props { resourceUri: string; @@ -130,6 +130,10 @@ export class SharedPreferences extends PureComponent { const { disabled } = this.props; const styles = getStyles(); const languages = getLanguageOptions(); + let currentThemeOption = this.themeOptions[0].value; + if (theme?.length) { + currentThemeOption = this.themeOptions.find((item) => item.value === theme)?.value; + } return (
@@ -139,7 +143,7 @@ export class SharedPreferences extends PureComponent { item.value === theme)?.value} + value={currentThemeOption} onChange={this.onThemeChanged} /> @@ -181,7 +185,7 @@ export class SharedPreferences extends PureComponent { data-testid={selectors.components.WeekStartPicker.containerV2} > diff --git a/public/app/plugins/panel/state-timeline/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx similarity index 88% rename from public/app/plugins/panel/state-timeline/TimelineChart.tsx rename to public/app/core/components/TimelineChart/TimelineChart.tsx index cd853ded8ac..9126adaea70 100644 --- a/public/app/plugins/panel/state-timeline/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; -import { VisibilityMode } from '@grafana/schema'; +import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; import { PanelContext, PanelContextRoot, @@ -13,17 +13,14 @@ import { VizLegendItem, } from '@grafana/ui'; -import { TimelineMode, TimelineOptions, TimelineValueAlignment } from './types'; -import { preparePlotConfigBuilder } from './utils'; +import { preparePlotConfigBuilder, TimelineMode } from './utils'; /** * @alpha */ -export interface TimelineProps - extends TimelineOptions, - Omit { +export interface TimelineProps extends Omit { mode: TimelineMode; - rowHeight: number; + rowHeight?: number; showValue: VisibilityMode; alignValue?: TimelineValueAlignment; colWidth?: number; diff --git a/public/app/plugins/panel/state-timeline/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts similarity index 96% rename from public/app/plugins/panel/state-timeline/timeline.ts rename to public/app/core/components/TimelineChart/timeline.ts index eeb0c634fdf..e6f968fcd39 100644 --- a/public/app/plugins/panel/state-timeline/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -2,12 +2,14 @@ import uPlot, { Cursor, Series } from 'uplot'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; -import { VisibilityMode } from '@grafana/schema'; +import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG'; import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute'; import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree'; +import { PanelFieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen'; +import { PanelFieldConfig as StatusHistoryFieldConfig } from 'app/plugins/panel/status-history/panelcfg.gen'; -import { TimelineFieldConfig, TimelineMode, TimelineValueAlignment } from './types'; +import { TimelineMode } from './utils'; const { round, min, ceil } = Math; @@ -39,7 +41,7 @@ export interface TimelineCoreOptions { mode: TimelineMode; alignValue?: TimelineValueAlignment; numSeries: number; - rowHeight: number; + rowHeight?: number; colWidth?: number; theme: GrafanaTheme2; showValue: VisibilityMode; @@ -49,7 +51,7 @@ export interface TimelineCoreOptions { label: (seriesIdx: number) => string; getTimeRange: () => TimeRange; formatValue?: (seriesIdx: number, value: any) => string; - getFieldConfig: (seriesIdx: number) => TimelineFieldConfig; + getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig; onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void; onLeave: () => void; } @@ -567,7 +569,7 @@ export function getConfig(opts: TimelineCoreOptions) { }; } -function getFillColor(fieldConfig: TimelineFieldConfig, color: string) { +function getFillColor(fieldConfig: { fillOpacity?: number; lineWidth?: number }, color: string) { // if #rgba with pre-existing alpha. ignore fieldConfig.fillOpacity // e.g. thresholds with opacity if (color[0] === '#' && color.length === 9) { diff --git a/public/app/plugins/panel/state-timeline/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts similarity index 100% rename from public/app/plugins/panel/state-timeline/utils.test.ts rename to public/app/core/components/TimelineChart/utils.test.ts diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/core/components/TimelineChart/utils.ts similarity index 93% rename from public/app/plugins/panel/state-timeline/utils.ts rename to public/app/core/components/TimelineChart/utils.ts index ba2b7db6a8c..d13700390e6 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -24,7 +24,15 @@ import { TimeRange, } from '@grafana/data'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; -import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema'; +import { + VizLegendOptions, + AxisPlacement, + ScaleDirection, + ScaleOrientation, + VisibilityMode, + TimelineValueAlignment, + HideableFieldConfig, +} from '@grafana/schema'; import { FIXED_UNIT, SeriesVisibilityChangeMode, @@ -38,9 +46,37 @@ import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types' import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils'; import { getConfig, TimelineCoreOptions } from './timeline'; -import { TimelineFieldConfig, TimelineOptions } from './types'; -const defaultConfig: TimelineFieldConfig = { +/** + * @internal + */ +interface UPlotConfigOptions { + frame: DataFrame; + theme: GrafanaTheme2; + mode: TimelineMode; + sync?: () => DashboardCursorSync; + rowHeight?: number; + colWidth?: number; + showValue: VisibilityMode; + alignValue?: TimelineValueAlignment; + mergeValues?: boolean; + getValueColor: (frameIdx: number, fieldIdx: number, value: any) => string; +} + +/** + * @internal + */ +interface PanelFieldConfig extends HideableFieldConfig { + fillOpacity?: number; + lineWidth?: number; +} + +export enum TimelineMode { + Changes = 'changes', + Samples = 'samples', +} + +const defaultConfig: PanelFieldConfig = { lineWidth: 0, fillOpacity: 80, }; @@ -52,7 +88,7 @@ export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityCh return SeriesVisibilityChangeMode.ToggleSelection; } -export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ +export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ frame, theme, timeZones, @@ -92,12 +128,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ }; const opts: TimelineCoreOptions = { - // should expose in panel config mode: mode!, numSeries: frame.fields.length - 1, isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]), mergeValues, - rowHeight: rowHeight!, + rowHeight: rowHeight, colWidth: colWidth, showValue: showValue!, alignValue, @@ -209,8 +244,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ } const field = frame.fields[i]; - const config: FieldConfig = field.config; - const customConfig: TimelineFieldConfig = { + const config: FieldConfig = field.config; + const customConfig: PanelFieldConfig = { ...defaultConfig, ...config.custom, }; @@ -521,9 +556,10 @@ export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLe const items: VizLegendItem[] = []; const fieldConfig = fields[0].config; const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed; + const thresholds = fieldConfig.thresholds; // If thresholds are enabled show each step in the legend - if (colorMode === FieldColorModeId.Thresholds) { + if (colorMode === FieldColorModeId.Thresholds && thresholds?.steps && thresholds.steps.length > 1) { return getThresholdItems(fieldConfig, theme); } diff --git a/public/app/core/components/TraceToLogs/KeyValueInput.tsx b/public/app/core/components/TraceToLogs/TagMappingInput.tsx similarity index 81% rename from public/app/core/components/TraceToLogs/KeyValueInput.tsx rename to public/app/core/components/TraceToLogs/TagMappingInput.tsx index bc30d96ea5e..8757260a45a 100644 --- a/public/app/core/components/TraceToLogs/KeyValueInput.tsx +++ b/public/app/core/components/TraceToLogs/TagMappingInput.tsx @@ -1,26 +1,16 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, KeyValue } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { SegmentInput, useStyles2, InlineLabel, Icon } from '@grafana/ui'; -const EQ_WIDTH = 3; // = 24px in inline label - interface Props { - values: Array>; - onChange: (values: Array>) => void; + values: Array<{ key: string; value?: string }>; + onChange: (values: Array<{ key: string; value?: string }>) => void; id?: string; - keyPlaceholder?: string; - valuePlaceholder?: string; } -const KeyValueInput = ({ - values, - onChange, - id, - keyPlaceholder = 'Key', - valuePlaceholder = 'Value (optional)', -}: Props) => { +export const TagMappingInput = ({ values, onChange, id }: Props) => { const styles = useStyles2(getStyles); return ( @@ -30,7 +20,7 @@ const KeyValueInput = ({
{ onChange( @@ -43,13 +33,13 @@ const KeyValueInput = ({ ); }} /> - - = + + as { onChange( values.map((v, i) => { @@ -95,8 +85,6 @@ const KeyValueInput = ({ ); }; -export default KeyValueInput; - const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` display: flex; @@ -110,5 +98,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, operator: css` color: ${theme.v1.palette.orange}; + width: auto; `, }); diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx new file mode 100644 index 00000000000..d3b5fb8254d --- /dev/null +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataSourceInstanceSettings, DataSourceSettings } from '@grafana/data'; +import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; + +import { TraceToLogsData, TraceToLogsSettings } from './TraceToLogsSettings'; + +const defaultOptionsOldFormat: DataSourceSettings = { + jsonData: { + tracesToLogs: { + datasourceUid: 'loki1_uid', + tags: ['someTag'], + mapTagNamesEnabled: false, + spanStartTimeShift: '1m', + spanEndTimeShift: '1m', + filterByTraceID: true, + filterBySpanID: true, + }, + }, +} as unknown as DataSourceSettings; + +const defaultOptionsNewFormat: DataSourceSettings = { + jsonData: { + tracesToLogsV2: { + datasourceUid: 'loki1_uid', + tags: [{ key: 'someTag', value: 'newName' }], + spanStartTimeShift: '1m', + spanEndTimeShift: '1m', + filterByTraceID: true, + filterBySpanID: true, + customQuery: true, + query: '{${__tags}}', + }, + }, +} as unknown as DataSourceSettings; + +const lokiSettings = { + uid: 'loki1_uid', + name: 'loki1', + type: 'loki', + meta: { info: { logos: { small: '' } } }, +} as unknown as DataSourceInstanceSettings; + +describe('TraceToLogsSettings', () => { + beforeAll(() => { + setDataSourceSrv({ + getList() { + return [lokiSettings]; + }, + getInstanceSettings() { + return lokiSettings; + }, + } as unknown as DataSourceSrv); + }); + + it('should render old format without error', () => { + expect(() => + render( {}} />) + ).not.toThrow(); + }); + + it('should render new format without error', () => { + expect(() => + render( {}} />) + ).not.toThrow(); + }); + + it('should render and transform data from old format correctly', () => { + render( {}} />); + expect(screen.getByText('someTag')).toBeInTheDocument(); + expect((screen.getByLabelText('Use custom query') as HTMLInputElement).checked).toBeFalsy(); + expect((screen.getByLabelText('Filter by trace ID') as HTMLInputElement).checked).toBeTruthy(); + expect((screen.getByLabelText('Filter by span ID') as HTMLInputElement).checked).toBeTruthy(); + }); + + it('renders old mapped tags correctly', () => { + const options = { + ...defaultOptionsOldFormat, + jsonData: { + ...defaultOptionsOldFormat.jsonData, + tracesToLogs: { + ...defaultOptionsOldFormat.jsonData.tracesToLogs, + tags: undefined, + mappedTags: [{ key: 'someTag', value: 'withNewName' }], + mapTagNamesEnabled: true, + }, + }, + }; + + render( {}} />); + expect(screen.getByText('someTag')).toBeInTheDocument(); + expect(screen.getByText('withNewName')).toBeInTheDocument(); + }); + + it('transforms old format to new on change', async () => { + const changeMock = jest.fn(); + render(); + const checkBox = screen.getByLabelText('Filter by trace ID'); + await userEvent.click(checkBox); + expect(changeMock.mock.calls[0]).toEqual([ + { + jsonData: { + tracesToLogs: undefined, + tracesToLogsV2: { + customQuery: false, + datasourceUid: 'loki1_uid', + filterBySpanID: true, + filterByTraceID: false, + spanEndTimeShift: '1m', + spanStartTimeShift: '1m', + tags: [ + { + key: 'someTag', + }, + ], + }, + }, + }, + ]); + }); +}); diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx index b80b7320ed6..a0af64be2e2 100644 --- a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx @@ -1,23 +1,22 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { DataSourceJsonData, DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps, GrafanaTheme2, - KeyValue, - updateDatasourcePluginJsonDataOption, } from '@grafana/data'; import { DataSourcePicker } from '@grafana/runtime'; -import { InlineField, InlineFieldRow, Input, TagsInput, useStyles2, InlineSwitch } from '@grafana/ui'; +import { InlineField, InlineFieldRow, Input, useStyles2, InlineSwitch } from '@grafana/ui'; -import KeyValueInput from './KeyValueInput'; +import { TagMappingInput } from './TagMappingInput'; +// @deprecated use getTraceToLogsOptions to get the v2 version of this config from jsonData export interface TraceToLogsOptions { datasourceUid?: string; tags?: string[]; - mappedTags?: Array>; + mappedTags?: Array<{ key: string; value?: string }>; mapTagNamesEnabled?: boolean; spanStartTimeShift?: string; spanEndTimeShift?: string; @@ -26,8 +25,45 @@ export interface TraceToLogsOptions { lokiSearch?: boolean; // legacy } +export interface TraceToLogsOptionsV2 { + datasourceUid?: string; + tags?: Array<{ key: string; value?: string }>; + spanStartTimeShift?: string; + spanEndTimeShift?: string; + filterByTraceID?: boolean; + filterBySpanID?: boolean; + query?: string; + customQuery: boolean; +} + export interface TraceToLogsData extends DataSourceJsonData { tracesToLogs?: TraceToLogsOptions; + tracesToLogsV2?: TraceToLogsOptionsV2; +} + +/** + * Gets new version of the traceToLogs config from the json data either returning directly or transforming the old + * version to new and returning that. + */ +export function getTraceToLogsOptions(data?: TraceToLogsData): TraceToLogsOptionsV2 | undefined { + if (data?.tracesToLogsV2) { + return data.tracesToLogsV2; + } + if (!data?.tracesToLogs) { + return undefined; + } + const traceToLogs: TraceToLogsOptionsV2 = { + customQuery: false, + }; + traceToLogs.datasourceUid = data.tracesToLogs.datasourceUid; + traceToLogs.tags = data.tracesToLogs.mapTagNamesEnabled + ? data.tracesToLogs.mappedTags + : data.tracesToLogs.tags?.map((tag) => ({ key: tag })); + traceToLogs.filterByTraceID = data.tracesToLogs.filterByTraceID; + traceToLogs.filterBySpanID = data.tracesToLogs.filterBySpanID; + traceToLogs.spanStartTimeShift = data.tracesToLogs.spanStartTimeShift; + traceToLogs.spanEndTimeShift = data.tracesToLogs.spanEndTimeShift; + return traceToLogs; } interface Props extends DataSourcePluginOptionsEditorProps {} @@ -41,6 +77,31 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) { 'grafana-opensearch-datasource', // external ]; + const traceToLogs = useMemo( + (): TraceToLogsOptionsV2 => getTraceToLogsOptions(options.jsonData) || { customQuery: false }, + [options.jsonData] + ); + const { query = '', tags, customQuery } = traceToLogs; + + const updateTracesToLogs = useCallback( + (value: Partial) => { + // Cannot use updateDatasourcePluginJsonDataOption here as we need to update 2 keys, and they would overwrite each + // other as updateDatasourcePluginJsonDataOption isn't synchronized + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + tracesToLogsV2: { + ...traceToLogs, + ...value, + }, + tracesToLogs: undefined, + }, + }); + }, + [onOptionsChange, options, traceToLogs] + ); + return (

Trace to logs

@@ -54,171 +115,143 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) { supportedDataSourceTypes.includes(ds.type)} - current={options.jsonData.tracesToLogs?.datasourceUid} + current={traceToLogs.datasourceUid} noDefault={true} width={40} onChange={(ds: DataSourceInstanceSettings) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, + updateTracesToLogs({ datasourceUid: ds.uid, - tags: options.jsonData.tracesToLogs?.tags, }) } /> - {options.jsonData.tracesToLogs?.mapTagNamesEnabled ? ( - - - ({ key: tag })) ?? - [] - } - onChange={(v) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - mappedTags: v, - }) - } - /> - - - ) : ( - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - tags: tags, - }) - } - /> - - + updateTracesToLogs({ spanStartTimeShift: val })} + /> + updateTracesToLogs({ spanEndTimeShift: val })} + /> + + + + updateTracesToLogs({ tags: v })} /> + + + + updateTracesToLogs({ filterByTraceID: val })} + /> + updateTracesToLogs({ filterBySpanID: val })} + /> + + + + ) => + updateTracesToLogs({ customQuery: event.currentTarget.checked }) + } + /> + + + + {customQuery && ( + + updateTracesToLogs({ query: e.currentTarget.value })} + /> + )} - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - mapTagNamesEnabled: event.currentTarget.checked, - }) - } - /> - - - - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - spanStartTimeShift: v.currentTarget.value, - }) - } - value={options.jsonData.tracesToLogs?.spanStartTimeShift || ''} - /> - - - - - - - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - spanEndTimeShift: v.currentTarget.value, - }) - } - value={options.jsonData.tracesToLogs?.spanEndTimeShift || ''} - /> - - - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - filterByTraceID: event.currentTarget.checked, - }) - } - /> - - - - - - ) => - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { - ...options.jsonData.tracesToLogs, - filterBySpanID: event.currentTarget.checked, - }) - } - /> - -
); } +interface IdFilterProps { + type: 'trace' | 'span'; + id: string; + value: boolean; + onChange: (val: boolean) => void; + disabled: boolean; +} +function IdFilter(props: IdFilterProps) { + return ( + + + ) => props.onChange(event.currentTarget.checked)} + /> + + + ); +} + +interface TimeRangeShiftProps { + type: 'start' | 'end'; + value: string; + onChange: (val: string) => void; +} +function TimeRangeShift(props: TimeRangeShiftProps) { + return ( + + + props.onChange(e.currentTarget.value)} + value={props.value} + /> + + + ); +} + const getStyles = (theme: GrafanaTheme2) => ({ infoText: css` padding-bottom: ${theme.spacing(2)}; diff --git a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx b/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx index f51c90469d4..1198a85dce3 100644 --- a/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx +++ b/public/app/core/components/TraceToMetrics/TraceToMetricsSettings.tsx @@ -5,17 +5,16 @@ import { DataSourceJsonData, DataSourcePluginOptionsEditorProps, GrafanaTheme2, - KeyValue, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; import { DataSourcePicker } from '@grafana/runtime'; import { Button, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; -import KeyValueInput from '../TraceToLogs/KeyValueInput'; +import { TagMappingInput } from '../TraceToLogs/TagMappingInput'; export interface TraceToMetricsOptions { datasourceUid?: string; - tags?: Array>; + tags?: Array<{ key: string; value: string }>; queries: TraceToMetricQuery[]; spanStartTimeShift?: string; spanEndTimeShift?: string; @@ -79,8 +78,7 @@ export function TraceToMetricsSettings({ options, onOptionsChange }: Props) { - updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', { diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index f51900e9571..3d9f0a5afa9 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -1,10 +1,11 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Modal, useStyles2 } from '@grafana/ui'; +import { getModKey } from 'app/core/utils/browser'; -const shortcuts = { +const getShortcuts = (modKey: string) => ({ Global: [ { keys: ['g', 'h'], description: 'Go to Home Dashboard' }, { keys: ['g', 'e'], description: 'Go to Explore' }, @@ -12,11 +13,11 @@ const shortcuts = { { keys: ['s', 'o'], description: 'Open search' }, { keys: ['esc'], description: 'Exit edit/setting views' }, { keys: ['h'], description: 'Show all keyboard shortcuts' }, - { keys: ['mod+k'], description: 'Open command palette' }, + { keys: [`${modKey}+k`], description: 'Open command palette' }, { keys: ['c', 't'], description: 'Change theme' }, ], Dashboard: [ - { keys: ['mod+s'], description: 'Save dashboard' }, + { keys: [`${modKey}+s`], description: 'Save dashboard' }, { keys: ['d', 'r'], description: 'Refresh all panels' }, { keys: ['d', 's'], description: 'Dashboard settings' }, { keys: ['d', 'v'], description: 'Toggle in-active / view mode' }, @@ -24,7 +25,7 @@ const shortcuts = { { keys: ['d', 'E'], description: 'Expand all rows' }, { keys: ['d', 'C'], description: 'Collapse all rows' }, { keys: ['d', 'a'], description: 'Toggle auto fit panels (experimental feature)' }, - { keys: ['mod+o'], description: 'Toggle shared graph crosshair' }, + { keys: [`${modKey}+o`], description: 'Toggle shared graph crosshair' }, { keys: ['d', 'l'], description: 'Toggle all panel legends' }, ], 'Focused Panel': [ @@ -50,7 +51,7 @@ const shortcuts = { description: 'Make time range absolute/permanent', }, ], -}; +}); export interface HelpModalProps { onDismiss: () => void; @@ -58,11 +59,10 @@ export interface HelpModalProps { export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { const styles = useStyles2(getStyles); + const modKey = useMemo(() => getModKey(), []); + const shortcuts = useMemo(() => getShortcuts(modKey), [modKey]); return ( -
- mod = CTRL on windows or linux and CMD key on Mac -
{Object.entries(shortcuts).map(([category, shortcuts], i) => (
diff --git a/public/app/core/history/RichHistoryRemoteStorage.test.ts b/public/app/core/history/RichHistoryRemoteStorage.test.ts index e01781b9c3a..2e70202c5f6 100644 --- a/public/app/core/history/RichHistoryRemoteStorage.test.ts +++ b/public/app/core/history/RichHistoryRemoteStorage.test.ts @@ -1,7 +1,9 @@ import { of } from 'rxjs'; +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; + import { DatasourceSrv } from '../../features/plugins/datasource_srv'; -import { RichHistoryQuery, UserPreferencesDTO } from '../../types'; +import { RichHistoryQuery } from '../../types'; import { SortOrder } from '../utils/richHistoryTypes'; import RichHistoryRemoteStorage, { RichHistoryRemoteStorageDTO } from './RichHistoryRemoteStorage'; diff --git a/public/app/core/services/PreferencesService.ts b/public/app/core/services/PreferencesService.ts index fe431964725..ea636f7daea 100644 --- a/public/app/core/services/PreferencesService.ts +++ b/public/app/core/services/PreferencesService.ts @@ -1,4 +1,4 @@ -import { UserPreferencesDTO } from 'app/types'; +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; import { backendSrv } from './backend_srv'; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index b48bbbe1650..51b7518e9d2 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -39,9 +39,11 @@ export class KeybindingSrv { this.bind('g a', this.openAlerting); this.bind('g p', this.goToProfile); this.bind('g e', this.goToExplore); - this.bind('s o', this.openSearch); + if (!config.featureToggles.topNavCommandPalette) { + this.bind('s o', this.openSearch); + this.bind('f', this.openSearch); + } this.bind('t a', this.makeAbsoluteTime); - this.bind('f', this.openSearch); this.bind('esc', this.exit); this.bindGlobalEsc(); } diff --git a/public/app/core/utils/browser.ts b/public/app/core/utils/browser.ts index 346dee58ee9..d47b8745e4b 100644 --- a/public/app/core/utils/browser.ts +++ b/public/app/core/utils/browser.ts @@ -32,3 +32,12 @@ export function checkBrowserCompatibility() { return true; } + +export function userAgentIsApple() { + const appleRe = /(iPhone|iPad|Mac)/; + return appleRe.test(navigator.platform); +} + +export function getModKey() { + return userAgentIsApple() ? 'cmd' : 'ctrl'; +} diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 73b38f7c180..dc84ebc0f84 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -39,7 +39,7 @@ interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { error?: UserAdminError; } -const SyncedOAuthLabels: string[] = ['GitHub', 'GitLab', 'OAuth']; +const SyncedOAuthLabels: string[] = ['OAuth']; export class UserAdminPage extends PureComponent { async componentDidMount() { @@ -113,6 +113,8 @@ export class UserAdminPage extends PureComponent { user?.isExternal && user?.authLabels?.some((r) => SyncedOAuthLabels.includes(r)); const isSAMLUser = user?.isExternal && user?.authLabels?.includes('SAML'); const isGoogleUser = user?.isExternal && user?.authLabels?.includes('Google'); + const isGithubUser = user?.isExternal && user?.authLabels?.includes('GitHub'); + const isGitLabUser = user?.isExternal && user?.authLabels?.includes('GitLab'); const isAuthProxyUser = user?.isExternal && user?.authLabels?.includes('Auth Proxy'); const isAzureADUser = user?.isExternal && user?.authLabels?.includes('AzureAD'); const isGrafanaComUser = user?.isExternal && user?.authLabels?.includes('grafana.com'); @@ -122,9 +124,11 @@ export class UserAdminPage extends PureComponent { !( isAuthProxyUser || isGoogleUser || + isGitLabUser || isOAuthUserWithSkippableSync || isSAMLUser || isLDAPUser || + isGithubUser || isAzureADUser || isJWTUser || isGrafanaComUser @@ -135,7 +139,9 @@ export class UserAdminPage extends PureComponent { (!config.auth.JWTAuthSkipOrgRoleSync && isJWTUser) || // both OAuthSkipOrgRoleUpdateSync and specific provider settings needs to be false for a user to be synced (!config.auth.OAuthSkipOrgRoleUpdateSync && !config.auth.GrafanaComSkipOrgRoleSync && isGrafanaComUser) || + (!config.auth.OAuthSkipOrgRoleUpdateSync && !config.auth.GithubSkipOrgRoleSync && isGithubUser) || (!config.auth.OAuthSkipOrgRoleUpdateSync && !config.auth.AzureADSkipOrgRoleSync && isAzureADUser) || + (!config.auth.OAuthSkipOrgRoleUpdateSync && !config.auth.GitLabSkipOrgRoleSync && isGitLabUser) || (!config.auth.OAuthSkipOrgRoleUpdateSync && !config.auth.GoogleSkipOrgRoleSync && isGoogleUser)); const pageNav: NavModelItem = { diff --git a/public/app/features/alerting/unified/Home.tsx b/public/app/features/alerting/unified/Home.tsx index f8e094ebbe9..ce1052853e1 100644 --- a/public/app/features/alerting/unified/Home.tsx +++ b/public/app/features/alerting/unified/Home.tsx @@ -55,10 +55,6 @@ export default function Home() {
-
diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 534aa43628f..44a8508b672 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -498,10 +498,10 @@ describe('RuleList', () => { expect(groups).toHaveLength(2); const filterInput = ui.rulesFilterInput.get(); - await userEvent.type(filterInput, '{{foo="bar"}'); + await userEvent.type(filterInput, 'label:foo=bar'); // Input is debounced so wait for it to be visible - await waitFor(() => expect(filterInput).toHaveValue('{foo="bar"}')); + await waitFor(() => expect(filterInput).toHaveValue('label:foo=bar')); // Group doesn't contain matching labels await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); @@ -517,17 +517,17 @@ describe('RuleList', () => { // Check for different label matchers await userEvent.clear(filterInput); - await userEvent.type(filterInput, '{{foo!="bar",foo!="baz"}'); + await userEvent.type(filterInput, 'label:foo!=bar label:foo!=baz'); // Group doesn't contain matching labels await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); await userEvent.clear(filterInput); - await userEvent.type(filterInput, '{{foo=~"b.+"}'); + await userEvent.type(filterInput, 'label:"foo=~b.+"'); await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2)); await userEvent.clear(filterInput); - await userEvent.type(filterInput, '{{region="US"}'); + await userEvent.type(filterInput, 'label:region=US'); await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); }); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index a46ddfc9523..c819a0b81bc 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -20,13 +20,12 @@ import { RuleListStateView } from './components/rules/RuleListStateView'; import { RuleStats } from './components/rules/RuleStats'; import RulesFilter from './components/rules/RulesFilter'; import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; -import { useFilteredRules } from './hooks/useFilteredRules'; +import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { fetchAllPromAndRulerRulesAction } from './state/actions'; import { useRulesAccess } from './utils/accessControlHooks'; import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; import { getAllRulesSourceNames } from './utils/datasource'; -import { getFiltersFromUrlParams } from './utils/misc'; const VIEWS = { groups: RuleListGroupView, @@ -42,8 +41,7 @@ const RuleList = withErrorBoundary( const [expandAll, setExpandAll] = useState(false); const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); - const filtersActive = Object.values(filters).some((filter) => filter !== undefined); + const { filterState, hasActiveFilters } = useRulesFilter(); const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess(); @@ -83,19 +81,20 @@ const RuleList = withErrorBoundary( const hasNoAlertRulesCreatedYet = allPromLoaded && allPromEmpty && promRequests.length > 0; const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); - const filteredNamespaces = useFilteredRules(combinedNamespaces); + const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - + setExpandAll(false)} /> {!hasNoAlertRulesCreatedYet && ( <>
- {view === 'groups' && filtersActive && ( + {view === 'groups' && hasActiveFilters && ( - {previewLoaded && !previewHasAlerts && ( - - There are no firing alerts for your query. - - )} - {previewHasAlerts && } -
+ {showPreviewAlertsButton && ( +
+ + {previewLoaded && !previewHasAlerts && ( + + There are no firing alerts for your query. + + )} + {previewHasAlerts && } +
+ )} ); }; 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 d985d37012a..26432889163 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -23,12 +23,16 @@ export const NotificationsStep = () => { return (
- {!hasLabelsDefined && ( + {!hasLabelsDefined && type !== RuleFormType.cloudRecording && ( Root route – default for all alerts 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 ce06d292ea3..4b426163f84 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 @@ -16,28 +16,29 @@ import { ExpressionEditor } from '../ExpressionEditor'; import { ExpressionsEditor } from '../ExpressionsEditor'; import { QueryEditor } from '../QueryEditor'; import { RuleEditorSection } from '../RuleEditorSection'; -import { refIdExists } from '../util'; +import { errorFromSeries, refIdExists } from '../util'; import { AlertType } from './AlertType'; import { - duplicateQuery, addNewDataQuery, addNewExpression, + duplicateQuery, queriesAndExpressionsReducer, removeExpression, rewireExpressions, setDataQueries, updateExpression, updateExpressionRefId, - updateExpressionType, updateExpressionTimeRange, + updateExpressionType, } from './reducer'; interface Props { editingExistingRule: boolean; + onDataChange: (error: string) => void; } -export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { +export const QueryAndExpressionsStep: FC = ({ editingExistingRule, onDataChange }) => { const runner = useRef(new AlertingQueryRunner()); const { setValue, @@ -104,6 +105,30 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { const emptyQueries = queries.length === 0; + useEffect(() => { + const currentCondition = getValues('condition'); + + if (!currentCondition) { + return; + } + + const error = errorFromSeries(panelData[currentCondition]?.series || []); + onDataChange(error?.message || ''); + }, [panelData, getValues, onDataChange]); + + const handleSetCondition = useCallback( + (refId: string | null) => { + if (!refId) { + return; + } + + runQueries(); //we need to run the queries to know if the condition is valid + + setValue('condition', refId); + }, + [runQueries, setValue] + ); + const onUpdateRefId = useCallback( (oldRefId: string, newRefId: string) => { const newRefIdExists = refIdExists(queries, newRefId); @@ -116,10 +141,10 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { // update condition too if refId was updated if (condition === oldRefId) { - setValue('condition', newRefId); + handleSetCondition(newRefId); } }, - [condition, queries, setValue] + [condition, queries, handleSetCondition] ); const onChangeQueries = useCallback( @@ -147,9 +172,9 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { useEffect(() => { if (!refIdExists(queries, condition)) { const lastRefId = queries.at(-1)?.refId ?? null; - setValue('condition', lastRefId); + handleSetCondition(lastRefId); } - }, [condition, queries, setValue]); + }, [condition, queries, handleSetCondition]); return ( @@ -161,7 +186,13 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { { - return ; + return ( + + ); }} control={control} rules={{ @@ -183,18 +214,14 @@ export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => { onDuplicateQuery={onDuplicateQuery} panelData={panelData} condition={condition} - onSetCondition={(refId) => { - setValue('condition', refId); - }} + onSetCondition={handleSetCondition} /> {/* Expression Queries */} { - setValue('condition', refId); - }} + onSetCondition={handleSetCondition} onRemoveExpression={(refId) => { dispatch(removeExpression(refId)); }} diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx new file mode 100644 index 00000000000..f46fdb629a2 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { + mockCombinedRule, + mockDataSource, + mockPromAlertingRule, + mockRulerAlertingRule, + mockRulerRecordingRule, + mockRulerRuleGroup, + mockStore, + someRulerRules, +} from '../../mocks'; +import { GRAFANA_DATASOURCE_NAME } from '../../utils/datasource'; + +import { CombinedGroupAndNameSpace, EditCloudGroupModal, ModalProps } from './EditRuleGroupModal'; + +const dsSettings = mockDataSource({ + name: 'Prometheus-1', + uid: 'Prometheus-1', +}); + +export const someCloudRulerRules: RulerRulesConfigDTO = { + namespace1: [ + mockRulerRuleGroup({ + name: 'group1', + rules: [ + mockRulerRecordingRule({ + record: 'instance:node_num_cpu:sum', + expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))', + labels: { type: 'cpu' }, + }), + mockRulerAlertingRule({ alert: 'nonRecordingRule' }), + ], + }), + ], +}; + +export const onlyRecordingRulerRules: RulerRulesConfigDTO = { + namespace1: [ + mockRulerRuleGroup({ + name: 'group1', + rules: [ + mockRulerRecordingRule({ + record: 'instance:node_num_cpu:sum', + expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))', + labels: { type: 'cpu' }, + }), + ], + }), + ], +}; + +const grafanaNamespace: CombinedRuleNamespace = { + name: 'namespace1', + rulesSource: dsSettings, + groups: [ + { + name: 'group1', + rules: [ + mockCombinedRule({ + namespace: { + groups: [], + name: 'namespace1', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: mockRulerAlertingRule(), + }), + ], + }, + ], +}; + +const group1: CombinedRuleGroup = { + name: 'group1', + rules: [ + mockCombinedRule({ + namespace: { + groups: [], + name: 'namespace1', + rulesSource: mockDataSource({ name: 'Prometheus-1' }), + }, + promRule: mockPromAlertingRule({ name: 'nonRecordingRule' }), + rulerRule: mockRulerAlertingRule({ alert: 'recordingRule' }), + }), + ], +}; + +const nameSpaceAndGroup: CombinedGroupAndNameSpace = { + namespace: grafanaNamespace, + group: group1, +}; +const defaultProps: ModalProps = { + nameSpaceAndGroup: nameSpaceAndGroup, + sourceName: 'Prometheus-1', + groupInterval: '1m', + onClose: jest.fn(), +}; + +jest.mock('app/types', () => ({ + ...jest.requireActual('app/types'), + useDispatch: () => jest.fn(), +})); + +function getProvidersWrapper(cloudRules?: RulerRulesConfigDTO) { + return function Wrapper({ children }: React.PropsWithChildren<{}>) { + const store = mockStore((store) => { + store.unifiedAlerting.rulerRules[GRAFANA_DATASOURCE_NAME] = { + loading: false, + dispatched: true, + result: someRulerRules, + }; + store.unifiedAlerting.rulerRules['Prometheus-1'] = { + loading: false, + dispatched: true, + result: cloudRules ?? someCloudRulerRules, + }; + }); + + return {children}; + }; +} + +describe('EditGroupModal component on cloud alert rules', () => { + it('Should show alert table in case of having some non-recording rules in the group', () => { + render(, { + wrapper: getProvidersWrapper(), + }); + expect(screen.getByText(/nonRecordingRule/i)).toBeInTheDocument(); + }); + it('Should not show alert table in case of not having some non-recording rules in the group', () => { + render(, { + wrapper: getProvidersWrapper(onlyRecordingRulerRules), + }); + expect(screen.queryByText(/nonRecordingRule/i)).not.toBeInTheDocument(); + expect(screen.getByText(/this group does not contain alert rules\./i)); + }); +}); +describe('EditGroupModal component on grafana-managed alert rules', () => { + it('Should show alert table', () => { + render(, { + wrapper: getProvidersWrapper(), + }); + expect(screen.getByText(/alert1/i)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 3b02777b07a..d7fd81e8e40 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -4,19 +4,19 @@ import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-ho import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Modal, Button, Field, Input, useStyles2, Label, Badge } from '@grafana/ui'; +import { Badge, Button, Field, Input, Label, Modal, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; -import { RulerRulesConfigDTO, RulerRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; -import { isAlertingRulerRule, isGrafanaRulerRule } from '../../utils/rules'; +import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules'; import { parsePrometheusDuration } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { InfoIcon } from '../InfoIcon'; @@ -122,24 +122,14 @@ export const safeParseDurationstr = (duration: string): number => { type AlertsWithForTableColumnProps = DynamicTableColumnProps; type AlertsWithForTableProps = DynamicTableItemProps; -export const RulesForGroupTable = ({ - rulerRules, - groupName, - folderName, -}: { - rulerRules: RulerRulesConfigDTO | null | undefined; - groupName: string; - folderName: string; -}) => { +export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => { const styles = useStyles2(getStyles); - const group = getGroupFromRuler(rulerRules, groupName, folderName); - const rules: RulerRuleDTO[] = group?.rules ?? []; const { watch } = useFormContext(); const currentInterval = watch('groupInterval'); const unknownCurrentInterval = !Boolean(currentInterval); - const rows: AlertsWithForTableProps[] = rules + const rows: AlertsWithForTableProps[] = rulesWithoutRecordingRules .slice() .map((rule: RulerRuleDTO, index) => ({ id: index, @@ -198,7 +188,7 @@ export const RulesForGroupTable = ({ ); }; -interface CombinedGroupAndNameSpace { +export interface CombinedGroupAndNameSpace { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; } @@ -206,7 +196,7 @@ interface GroupAndNameSpaceNames { namespace: string; group: string; } -interface ModalProps { +export interface ModalProps { nameSpaceAndGroup: CombinedGroupAndNameSpace | GroupAndNameSpaceNames; sourceName: string; groupInterval: string; @@ -328,6 +318,11 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const groupfoldersForSource = rulerRuleRequests[sourceName]; + const groupWithRules = getGroupFromRuler(groupfoldersForSource?.result, groupName, nameSpaceName); + const rulesWithoutRecordingRules: RulerRuleDTO[] = + groupWithRules?.rules.filter((rule: RulerRuleDTO) => !isRecordingRulerRule(rule)) ?? []; + const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0; + return (
- {rulerRuleRequests && ( + {rulerRuleRequests && !hasSomeNoRecordingRules &&
This group does not contain alert rules.
} + {rulerRuleRequests && hasSomeNoRecordingRules && ( <>
List of rules that belong to this group
#Evaluations column represents the number of evaluations needed before alert starts firing.
- + )} diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index 1ec596341db..66b0d43f653 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -1,17 +1,19 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { debounce } from 'lodash'; import React, { FormEvent, useState } from 'react'; import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { DataSourcePicker, logInfo } from '@grafana/runtime'; -import { Button, Field, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, Field, Icon, Input, Label, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { LogMessages } from '../../Analytics'; -import { getFiltersFromUrlParams } from '../../utils/misc'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { RuleHealth } from '../../search/rulesSearchParser'; import { alertStateToReadable } from '../../utils/rules'; +import { HoverCard } from '../HoverCard'; const ViewOptions: SelectableValue[] = [ { @@ -42,14 +44,25 @@ const RuleTypeOptions: SelectableValue[] = [ }, ]; -const RulesFilter = () => { +const RuleHealthOptions: SelectableValue[] = [ + { label: 'Ok', value: RuleHealth.Ok }, + { label: 'No Data', value: RuleHealth.NoData }, + { label: 'Error', value: RuleHealth.Error }, +]; + +interface RulesFilerProps { + onFilterCleared?: () => void; +} + +const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => { const [queryParams, setQueryParams] = useQueryParams(); + // This key is used to force a rerender on the inputs when the filters are cleared const [filterKey, setFilterKey] = useState(Math.floor(Math.random() * 100)); const dataSourceKey = `dataSource-${filterKey}`; const queryStringKey = `queryString-${filterKey}`; - const { dataSource, alertState, queryString, ruleType } = getFiltersFromUrlParams(queryParams); + const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter(); const styles = useStyles2(getStyles); const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({ @@ -58,21 +71,24 @@ const RulesFilter = () => { })); const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => { - setQueryParams({ dataSource: dataSourceValue.name }); + updateFilters({ ...filterState, dataSourceName: dataSourceValue.name }); + setFilterKey((key) => key + 1); }; const clearDataSource = () => { - setQueryParams({ dataSource: null }); + updateFilters({ ...filterState, dataSourceName: undefined }); + setFilterKey((key) => key + 1); }; const handleQueryStringChange = debounce((e: FormEvent) => { const target = e.target as HTMLInputElement; - setQueryParams({ queryString: target.value || null }); + setSearchQuery(target.value); }, 600); - const handleAlertStateChange = (value: string) => { + const handleAlertStateChange = (value: PromAlertingRuleState) => { logInfo(LogMessages.clickingAlertStateFilters); - setQueryParams({ alertState: value }); + updateFilters({ ...filterState, ruleState: value }); + setFilterKey((key) => key + 1); }; const handleViewChange = (view: string) => { @@ -80,100 +96,97 @@ const RulesFilter = () => { }; const handleRuleTypeChange = (ruleType: PromRuleType) => { - setQueryParams({ ruleType }); + updateFilters({ ...filterState, ruleType }); + setFilterKey((key) => key + 1); + }; + + const handleRuleHealthChange = (ruleHealth: RuleHealth) => { + updateFilters({ ...filterState, ruleHealth }); + setFilterKey((key) => key + 1); }; const handleClearFiltersClick = () => { - setQueryParams({ - alertState: null, - queryString: null, - dataSource: null, - ruleType: null, - }); + setSearchQuery(undefined); + onFilterCleared(); + setTimeout(() => setFilterKey(filterKey + 1), 100); }; const searchIcon = ; return (
- - - -
-
- - - Search by label - - Filter rules and alerts using label querying, ex: - {`{severity="critical", instance=~"cluster-us-.+"}`} -
- } - > - - - - - } - > - + + + -
+
- +
-
+
+ +
+
+
-
- - -
-
- {(dataSource || alertState || queryString || ruleType) && ( -
- -
- )} -
+ + +
+ + +
+
+ {hasActiveFilters && ( +
+ +
+ )} + +
); }; @@ -181,32 +194,65 @@ const RulesFilter = () => { const getStyles = (theme: GrafanaTheme2) => { return { container: css` - display: flex; - flex-direction: column; - padding-bottom: ${theme.spacing(1)}; margin-bottom: ${theme.spacing(1)}; `, - inputWidth: css` - width: 340px; + dsPickerContainer: css` + width: 250px; flex-grow: 0; + margin: 0; `, - flexRow: css` - display: flex; - flex-direction: row; - align-items: flex-end; - width: 100%; - flex-wrap: wrap; - `, - spaceBetween: css` - justify-content: space-between; - `, - rowChild: css` - margin: ${theme.spacing(0, 1, 0, 0)}; - `, - clearButton: css` - margin-top: ${theme.spacing(1)}; + searchInput: css` + flex: 1; + margin: 0; `, }; }; +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); + + return ( +
+
Search syntax allows to query alert rules by the parameters defined below.
+
+
+
Filter type
+
Expression
+ + + + + + + + +
+
+ ); +} + +function HelpRow({ title, expr }: { title: string; expr: string }) { + const styles = useStyles2(helpStyles); + + return ( + <> +
{title}
+ {expr} + + ); +} + +const helpStyles = (theme: GrafanaTheme2) => ({ + grid: css` + display: grid; + grid-template-columns: max-content auto; + gap: ${theme.spacing(1)}; + align-items: center; + `, + code: css` + display: block; + text-align: center; + `, +}); + export default RulesFilter; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts new file mode 100644 index 00000000000..a809e148a61 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -0,0 +1,163 @@ +import { setDataSourceSrv } from '@grafana/runtime'; + +import { PromAlertingRuleState } from '../../../../types/unified-alerting-dto'; +import { + mockAlertQuery, + mockCombinedRule, + mockCombinedRuleGroup, + mockCombinedRuleNamespace, + mockDataSource, + MockDataSourceSrv, + mockPromAlert, + mockPromAlertingRule, + mockRulerGrafanaRule, +} from '../mocks'; +import { RuleHealth } from '../search/rulesSearchParser'; +import { getFilter } from '../utils/search'; + +import { filterRules } from './useFilteredRules'; + +const dataSources = { + prometheus: mockDataSource({ uid: 'prom-1', name: 'prometheus' }), + loki: mockDataSource({ uid: 'loki-1', name: 'loki' }), +}; +beforeAll(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); +}); + +describe('filterRules', function () { + it('should filter out rules by name filter', function () { + const rules = [mockCombinedRule({ name: 'High CPU usage' }), mockCombinedRule({ name: 'Memory too low' })]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ ruleName: 'cpu' })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage'); + }); + + it('should filter out rules by evaluation group name', function () { + const ns = mockCombinedRuleNamespace({ + groups: [ + mockCombinedRuleGroup('Performance group', [mockCombinedRule({ name: 'High CPU usage' })]), + mockCombinedRuleGroup('Availability group', [mockCombinedRule({ name: 'Memory too low' })]), + ], + }); + + const filtered = filterRules([ns], getFilter({ groupName: 'availability' })); + + expect(filtered[0].groups).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by label filter', function () { + const rules = [ + mockCombinedRule({ name: 'High CPU usage', labels: { severity: 'warning' } }), + mockCombinedRule({ name: 'Memory too low', labels: { severity: 'critical' } }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ labels: ['severity=critical'] })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by alert instance labels', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ alerts: [mockPromAlert({ labels: { severity: 'warning' } })] }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ labels: { severity: 'critical' }, alerts: [] }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ labels: ['severity=warning'] })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage'); + }); + + it('should filter out rules by state filter', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ ruleState: PromAlertingRuleState.Firing })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by health filter', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ health: RuleHealth.Ok }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ health: RuleHealth.Error }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ ruleHealth: RuleHealth.Error })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by datasource', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + rulerRule: mockRulerGrafanaRule(undefined, { + data: [mockAlertQuery({ datasourceUid: dataSources.prometheus.uid })], + }), + }), + mockCombinedRule({ + name: 'Memory too low', + rulerRule: mockRulerGrafanaRule(undefined, { + data: [mockAlertQuery({ datasourceUid: dataSources.loki.uid })], + }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ dataSourceName: 'loki' })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 20cf18caf17..061c90a7ead 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,35 +1,107 @@ -import { useMemo } from 'react'; +import produce from 'immer'; +import { compact, isEmpty } from 'lodash'; +import { useCallback, useEffect, useMemo } from 'react'; import { getDataSourceSrv } from '@grafana/runtime'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { CombinedRuleGroup, CombinedRuleNamespace, FilterState } from 'app/types/unified-alerting'; -import { PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; -import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager'; +import { getSearchFilterFromQuery, RulesFilter, applySearchFilterToQuery } from '../search/rulesSearchParser'; +import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers } from '../utils/alertmanager'; import { isCloudRulesSource } from '../utils/datasource'; -import { getFiltersFromUrlParams } from '../utils/misc'; -import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules'; +import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; -export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => { - const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); +import { useURLSearchParams } from './useURLSearchParams'; - return useMemo(() => { - const filteredNamespaces = namespaces - // Filter by data source - // TODO: filter by multiple data sources for grafana-managed alerts - .filter(({ rulesSource }) => - filters.dataSource && isCloudRulesSource(rulesSource) ? rulesSource.name === filters.dataSource : true - ) - // If a namespace and group have rules that match the rules filters then keep them. - .reduce(reduceNamespaces(filters), [] as CombinedRuleNamespace[]); - return filteredNamespaces; - }, [namespaces, filters]); +export function useRulesFilter() { + const [queryParams, updateQueryParams] = useURLSearchParams(); + const searchQuery = queryParams.get('search') ?? ''; + + const filterState = getSearchFilterFromQuery(searchQuery); + const hasActiveFilters = Object.values(filterState).some((filter) => !isEmpty(filter)); + + const updateFilters = useCallback( + (newFilter: RulesFilter) => { + const newSearchQuery = applySearchFilterToQuery(searchQuery, newFilter); + updateQueryParams({ search: newSearchQuery }); + }, + [searchQuery, updateQueryParams] + ); + + const setSearchQuery = useCallback( + (newSearchQuery: string | undefined) => { + updateQueryParams({ search: newSearchQuery }); + }, + [updateQueryParams] + ); + + // Handle legacy filters + useEffect(() => { + const legacyFilters = { + dataSource: queryParams.get('dataSource') ?? undefined, + alertState: queryParams.get('alertState') ?? undefined, + ruleType: queryParams.get('ruleType') ?? undefined, + labels: parseMatchers(queryParams.get('queryString') ?? '').map(matcherToMatcherField), + }; + + const hasLegacyFilters = Object.values(legacyFilters).some((legacyFilter) => !isEmpty(legacyFilter)); + if (hasLegacyFilters) { + updateQueryParams({ dataSource: undefined, alertState: undefined, ruleType: undefined, queryString: undefined }); + // Existing query filters takes precedence over legacy ones + updateFilters( + produce(filterState, (draft) => { + draft.dataSourceName ??= legacyFilters.dataSource; + if (legacyFilters.alertState && isPromAlertingRuleState(legacyFilters.alertState)) { + draft.ruleState ??= legacyFilters.alertState; + } + if (legacyFilters.ruleType && isPromRuleType(legacyFilters.ruleType)) { + draft.ruleType ??= legacyFilters.ruleType; + } + if (draft.labels.length === 0 && legacyFilters.labels.length > 0) { + const legacyLabelsAsStrings = legacyFilters.labels.map( + ({ name, operator, value }) => `${name}${operator}${value}` + ); + draft.labels.push(...legacyLabelsAsStrings); + } + }) + ); + } + }, [queryParams, updateFilters, filterState, updateQueryParams]); + + return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters }; +} + +export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { + return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]); }; -const reduceNamespaces = (filters: FilterState) => { +export const filterRules = ( + namespaces: CombinedRuleNamespace[], + filterState: RulesFilter = { labels: [], freeFormWords: [] } +): CombinedRuleNamespace[] => { + return ( + namespaces + .filter((ns) => + filterState.namespace ? ns.name.toLowerCase().includes(filterState.namespace.toLowerCase()) : true + ) + .filter(({ rulesSource }) => + filterState.dataSourceName && isCloudRulesSource(rulesSource) + ? rulesSource.name === filterState.dataSourceName + : true + ) + // If a namespace and group have rules that match the rules filters then keep them. + .reduce(reduceNamespaces(filterState), [] as CombinedRuleNamespace[]) + ); +}; + +const reduceNamespaces = (filterStateFilters: RulesFilter) => { return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => { - const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]); + const groups = namespace.groups + .filter((g) => + filterStateFilters.groupName ? g.name.toLowerCase().includes(filterStateFilters.groupName.toLowerCase()) : true + ) + .reduce(reduceGroups(filterStateFilters), [] as CombinedRuleGroup[]); if (groups.length) { namespaceAcc.push({ @@ -43,35 +115,56 @@ const reduceNamespaces = (filters: FilterState) => { }; // Reduces groups to only groups that have rules matching the filters -const reduceGroups = (filters: FilterState) => { +const reduceGroups = (filterState: RulesFilter) => { return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => { const rules = group.rules.filter((rule) => { - if (filters.ruleType && filters.ruleType !== rule.promRule?.type) { + if (filterState.ruleType && filterState.ruleType !== rule.promRule?.type) { return false; } - if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) { - return false; - } - // Query strings can match alert name, label keys, and label values - if (filters.queryString) { - const normalizedQueryString = filters.queryString.toLocaleLowerCase(); - const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString); - const matchers = parseMatchers(filters.queryString); - const doRuleLabelsMatchQuery = labelsMatchMatchers(rule.labels, matchers); + const doesNotQueryDs = isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filterState); + if (filterState.dataSourceName && doesNotQueryDs) { + return false; + } + + const ruleNameLc = rule.name?.toLocaleLowerCase(); + // Free Form Query is used to filter by rule name + if ( + filterState.freeFormWords.length > 0 && + !filterState.freeFormWords.every((w) => ruleNameLc.includes(w.toLocaleLowerCase())) + ) { + return false; + } + + if (filterState.ruleName && !rule.name?.toLocaleLowerCase().includes(filterState.ruleName.toLocaleLowerCase())) { + return false; + } + + if (filterState.ruleHealth && rule.promRule) { + const ruleHealth = getRuleHealth(rule.promRule.health); + return filterState.ruleHealth === ruleHealth; + } + + // Query strings can match alert name, label keys, and label values + if (filterState.labels.length > 0) { + // const matchers = parseMatchers(filters.queryString); + const matchers = compact(filterState.labels.map(looseParseMatcher)); + + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels, matchers); const doAlertsContainMatchingLabels = + matchers.length > 0 && rule.promRule && rule.promRule.type === PromRuleType.Alerting && rule.promRule.alerts && rule.promRule.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers)); - if (!(doesNameContainsQueryString || doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) { + if (!(doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) { return false; } } if ( - filters.alertState && - !(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filters.alertState) + filterState.ruleState && + !(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filterState.ruleState) ) { return false; } @@ -88,8 +181,17 @@ const reduceGroups = (filters: FilterState) => { }; }; -const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterState): boolean => { - if (!filter.dataSource) { +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => { + if (!filterState.dataSourceName) { return true; } @@ -98,6 +200,6 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterStat return false; } const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid); - return ds?.name === filter.dataSource; + return ds?.name === filterState.dataSourceName; }); }; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index e0f524eb91e..517f4142be5 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -23,14 +23,25 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction, FolderDTO, StoreState } from 'app/types'; -import { Alert, AlertingRule, CombinedRule, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; import { + Alert, + AlertingRule, + CombinedRule, + CombinedRuleGroup, + CombinedRuleNamespace, + RecordingRule, + RuleGroup, + RuleNamespace, +} from 'app/types/unified-alerting'; +import { + AlertQuery, GrafanaAlertStateDecision, GrafanaRuleDefinition, PromAlertingRuleState, PromRuleType, RulerAlertingRuleDTO, RulerGrafanaRuleDTO, + RulerRecordingRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -124,6 +135,18 @@ export const mockRulerAlertingRule = (partial: Partial = { ...partial, }); +export const mockRulerRecordingRule = (partial: Partial = {}): RulerAlertingRuleDTO => ({ + alert: 'alert1', + expr: 'up = 1', + labels: { + severity: 'warning', + }, + annotations: { + summary: 'test alert', + }, + ...partial, +}); + export const mockRulerRuleGroup = (partial: Partial = {}): RulerRuleGroupDTO => ({ name: 'group1', rules: [mockRulerAlertingRule()], @@ -517,6 +540,29 @@ export function mockStore(recipe: (state: StoreState) => void) { return configureStore(produce(defaultState, recipe)); } +export function mockAlertQuery(query: Partial): AlertQuery { + return { + datasourceUid: '--uid--', + refId: 'A', + queryType: '', + model: { refId: 'A' }, + ...query, + }; +} + +export function mockCombinedRuleGroup(name: string, rules: CombinedRule[]): CombinedRuleGroup { + return { name, rules }; +} + +export function mockCombinedRuleNamespace(namespace: Partial): CombinedRuleNamespace { + return { + name: 'Grafana', + groups: [], + rulesSource: 'grafana', + ...namespace, + }; +} + export function getGrafanaRule(override?: Partial) { return mockCombinedRule({ namespace: { diff --git a/public/app/features/alerting/unified/search/README.md b/public/app/features/alerting/unified/search/README.md new file mode 100644 index 00000000000..c550edffe6c --- /dev/null +++ b/public/app/features/alerting/unified/search/README.md @@ -0,0 +1,27 @@ +# Alerting search syntax + +## Lezer grammar + +Alerting uses the [Lezer](https://lezer.codemirror.net/) parser system to create a search syntax grammar. + +File [search.grammar](search.grammar) describes the search grammar. + +`@lezer/generator` package is used to generate [search.js](search.js) and [search.terms.js](search.terms.js) files which include a JS grammar parser. + +## Changing the grammar + +After making changes in the `search.grammar` file, a new version of the parser needs to be generated. +To do that, the following command needs to be run in the `public/app/features/alerting/unified/search` directory + +```sh +yarn dlx @lezer/generator search.grammar -o search.js +``` + +The command will re-create [search.js](search.js) and [search.terms.js](search.terms.js) files which are the files containing grammar parser. + +## Extensibility + +The `search.grammar` uses the [dialects feature](https://lezer.codemirror.net/docs/guide/#dialects) of Lezer to enable parsing of each filter term separately. + +This will allow us to have a single grammar file for handling filter expressions for all of our filters (e.g. Rules, Silences, Notification policies). +Then we can configure the required set of filters dynamically in the JS code using the parser. diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.test.ts b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts new file mode 100644 index 00000000000..4520655095c --- /dev/null +++ b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts @@ -0,0 +1,183 @@ +import { PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto'; +import { getFilter } from '../utils/search'; + +import { applySearchFilterToQuery, getSearchFilterFromQuery, RuleHealth } from './rulesSearchParser'; + +describe('Alert rules searchParser', () => { + describe('getSearchFilterFromQuery', () => { + it.each(['datasource:prometheus'])('should parse data source filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.dataSourceName).toBe('prometheus'); + }); + + it.each(['namespace:integrations-node'])('should parse namespace filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.namespace).toBe('integrations-node'); + }); + + it.each(['label:team label:region=emea'])('should parse label filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.labels).toHaveLength(2); + expect(filter.labels).toContain('team'); + expect(filter.labels).toContain('region=emea'); + }); + + it.each(['group:cpu-utilization'])('should parse group filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.groupName).toBe('cpu-utilization'); + }); + + it.each(['rule:cpu-80%-alert'])('should parse rule name filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleName).toBe('cpu-80%-alert'); + }); + + it.each([ + { query: 'state:firing', expectedFilter: PromAlertingRuleState.Firing }, + { query: 'state:inactive', expectedFilter: PromAlertingRuleState.Inactive }, + { query: 'state:pending', expectedFilter: PromAlertingRuleState.Pending }, + ])('should parse $expectedFilter rule state filter from "$query" query', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleState).toBe(expectedFilter); + }); + + it.each([ + { query: 'type:alerting', expectedFilter: PromRuleType.Alerting }, + { query: 'type:recording', expectedFilter: PromRuleType.Recording }, + ])('should parse $expectedFilter rule type filter from "$query" input', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleType).toBe(expectedFilter); + }); + + it.each([ + { query: 'health:ok', expectedFilter: RuleHealth.Ok }, + { query: 'health:nodata', expectedFilter: RuleHealth.NoData }, + { query: 'health:error', expectedFilter: RuleHealth.Error }, + ])('should parse RuleHealth $expectedFilter filter from "$query" query', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleHealth).toBe(expectedFilter); + }); + + it('should parse non-filtering words as free form query', () => { + const filter = getSearchFilterFromQuery('cpu usage rule'); + expect(filter.freeFormWords).toHaveLength(3); + expect(filter.freeFormWords).toContain('cpu'); + expect(filter.freeFormWords).toContain('usage'); + expect(filter.freeFormWords).toContain('rule'); + }); + + it('should parse free words with quotes', () => { + const query = '"hello world" hello world'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.freeFormWords).toEqual(['hello world', 'hello', 'world']); + }); + + it('should parse filter values with whitespaces when in quotes', () => { + const query = + 'datasource:"prom dev" namespace:"node one" label:"team=frontend us" group:"cpu alerts" rule:"cpu failure"'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prom dev'); + expect(filter.namespace).toBe('node one'); + expect(filter.labels).toContain('team=frontend us'); + expect(filter.groupName).toContain('cpu alerts'); + expect(filter.ruleName).toContain('cpu failure'); + }); + + it('should parse filter values with special characters', () => { + const query = + 'datasource:prom::dev/linux>>; namespace:"[{node}] (#20+)" label:_region=apac|emea\\nasa group:$20.00%$ rule:"cpu!! & memory.,?"'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prom::dev/linux>>;'); + expect(filter.namespace).toBe('[{node}] (#20+)'); + expect(filter.labels).toContain('_region=apac|emea\\nasa'); + expect(filter.groupName).toContain('$20.00%$'); + expect(filter.ruleName).toContain('cpu!! & memory.,?'); + }); + + it('should parse non-filter terms with colon as free form words', () => { + const query = 'cpu:high-utilization memory:overload'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.freeFormWords).toContain('cpu:high-utilization'); + expect(filter.freeFormWords).toContain('memory:overload'); + }); + + it('should parse mixed free form words and filters', () => { + const query = 'datasource:prometheus utilization label:team cpu'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prometheus'); + expect(filter.labels).toContain('team'); + expect(filter.freeFormWords).toContain('utilization'); + expect(filter.freeFormWords).toContain('cpu'); + }); + + it('should parse labels containing matchers', () => { + const query = 'label:region!=US label:"team=~fe.*devs" label:cluster!~ba.+'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.labels).toContain('region!=US'); + expect(filter.labels).toContain('team=~fe.*devs'); + expect(filter.labels).toContain('cluster!~ba.+'); + }); + }); + + describe('applySearchFilterToQuery', () => { + it('should apply filters to an empty query', () => { + const filter = getFilter({ + freeFormWords: ['cpu', 'eighty'], + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['team', 'region=apac'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + ruleType: PromRuleType.Alerting, + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + const query = applySearchFilterToQuery('', filter); + + expect(query).toBe( + 'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" state:firing type:alerting health:ok label:team label:region=apac cpu eighty' + ); + }); + + it('should update filters in existing query', () => { + const filter = getFilter({ + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['team', 'region=apac'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + }); + + const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"'; + const query = applySearchFilterToQuery(baseQuery, filter); + + expect(query).toBe( + 'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" label:team label:region=apac' + ); + }); + + it('should preserve the order of parameters when updating', () => { + const filter = getFilter({ + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['region=emea'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + }); + + const baseQuery = 'label:region=apac rule:"mem > 90%" group:memory namespace:mimir-global datasource:prometheus'; + const query = applySearchFilterToQuery(baseQuery, filter); + + expect(query).toBe( + 'label:region=emea rule:"cpu > 80%" group:cpu-usage namespace:/etc/prometheus datasource:"Mimir Dev"' + ); + }); + }); +}); diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.ts b/public/app/features/alerting/unified/search/rulesSearchParser.ts new file mode 100644 index 00000000000..c74bd0871bf --- /dev/null +++ b/public/app/features/alerting/unified/search/rulesSearchParser.ts @@ -0,0 +1,100 @@ +import { isPromAlertingRuleState, PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto'; +import { getRuleHealth, isPromRuleType } from '../utils/rules'; + +import * as terms from './search.terms'; +import { + applyFiltersToQuery, + FilterExpr, + FilterSupportedTerm, + parseQueryToFilter, + QueryFilterMapper, +} from './searchParser'; + +export interface RulesFilter { + freeFormWords: string[]; + namespace?: string; + groupName?: string; + ruleName?: string; + ruleState?: PromAlertingRuleState; + ruleType?: PromRuleType; + dataSourceName?: string; + labels: string[]; + ruleHealth?: RuleHealth; +} + +const filterSupportedTerms: FilterSupportedTerm[] = [ + FilterSupportedTerm.dataSource, + FilterSupportedTerm.nameSpace, + FilterSupportedTerm.label, + FilterSupportedTerm.group, + FilterSupportedTerm.rule, + FilterSupportedTerm.state, + FilterSupportedTerm.type, + FilterSupportedTerm.health, +]; + +export enum RuleHealth { + Ok = 'ok', + Error = 'error', + NoData = 'nodata', + Unknown = 'unknown', +} + +// Define how to map parsed tokens into the filter object +export function getSearchFilterFromQuery(query: string): RulesFilter { + const filter: RulesFilter = { labels: [], freeFormWords: [] }; + + const tokenToFilterMap: QueryFilterMapper = { + [terms.DataSourceToken]: (value) => (filter.dataSourceName = value), + [terms.NameSpaceToken]: (value) => (filter.namespace = value), + [terms.GroupToken]: (value) => (filter.groupName = value), + [terms.RuleToken]: (value) => (filter.ruleName = value), + [terms.LabelToken]: (value) => filter.labels.push(value), + [terms.StateToken]: (value) => (isPromAlertingRuleState(value) ? (filter.ruleState = value) : undefined), + [terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined), + [terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)), + [terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value), + }; + + parseQueryToFilter(query, filterSupportedTerms, tokenToFilterMap); + + return filter; +} + +// Reverse of the previous function +// Describes how to map the object into an array of tokens and values +export function applySearchFilterToQuery(query: string, filter: RulesFilter): string { + const filterStateArray: FilterExpr[] = []; + + // Convert filter object into an array + // It allows to pick filters from the array in the same order as they were applied in the original query + if (filter.dataSourceName) { + filterStateArray.push({ type: terms.DataSourceToken, value: filter.dataSourceName }); + } + if (filter.namespace) { + filterStateArray.push({ type: terms.NameSpaceToken, value: filter.namespace }); + } + if (filter.groupName) { + filterStateArray.push({ type: terms.GroupToken, value: filter.groupName }); + } + if (filter.ruleName) { + filterStateArray.push({ type: terms.RuleToken, value: filter.ruleName }); + } + if (filter.ruleState) { + filterStateArray.push({ type: terms.StateToken, value: filter.ruleState }); + } + if (filter.ruleType) { + filterStateArray.push({ type: terms.TypeToken, value: filter.ruleType }); + } + if (filter.ruleHealth) { + filterStateArray.push({ type: terms.HealthToken, value: filter.ruleHealth }); + } + if (filter.labels) { + filterStateArray.push(...filter.labels.map((l) => ({ type: terms.LabelToken, value: l }))); + } + if (filter.freeFormWords) { + filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word }))); + } + + return applyFiltersToQuery(query, filterSupportedTerms, filterStateArray); +} diff --git a/public/app/features/alerting/unified/search/search.grammar b/public/app/features/alerting/unified/search/search.grammar new file mode 100644 index 00000000000..a8d64b6dceb --- /dev/null +++ b/public/app/features/alerting/unified/search/search.grammar @@ -0,0 +1,54 @@ +@top AlertRuleSearch { expression+ } + +@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter } + +expression { (FilterExpression | FreeFormExpression) expression } + +FreeFormExpression { word (colon word)* | stringWithQuotes } + +FilterExpression { + filter | + filter | + filter | + filter | + filter | + filter | + filter | + filter +} + +filter { token FilterValue } + +@tokens { + colon { ":" } + + // Special characters (except colon, quotes and space), Latin characters, extended latin and emoji + allowedInputChar { $[!#$%&'()*+,-./] | $[\u{0030}-\u{0039}] | $[\u{003b}-\u{1eff}] | $[\u{2030}-\u{1faff}] } + word { allowedInputChar+ } + + allowedInputCharOrColon { allowedInputChar | colon } + allowedInputCharOrColonOrWhitespace { allowedInputCharOrColon | @whitespace } + stringWithQuotes { ("\"" allowedInputCharOrColonOrWhitespace+ "\"") } + + FilterValue { allowedInputCharOrColon+ | stringWithQuotes } + filterToken { type colon } + + DataSourceToken[@dialect=dataSourceFilter] { filterToken<"datasource"> } + NameSpaceToken[@dialect=nameSpaceFilter] { filterToken<"namespace"> } + LabelToken[@dialect=labelFilter] { filterToken<"label"> } + GroupToken[@dialect=groupFilter] { filterToken<"group"> } + RuleToken[@dialect=ruleFilter] { filterToken<"rule"> } + StateToken[@dialect=stateFilter] { filterToken<"state"> } + TypeToken[@dialect=typeFilter] { filterToken<"type"> } + HealthToken[@dialect=healthFilter] { filterToken<"health"> } + + @precedence { DataSourceToken, word } + @precedence { NameSpaceToken, word } + @precedence { LabelToken, word } + @precedence { GroupToken, word } + @precedence { RuleToken, word } + @precedence { StateToken, word } + @precedence { TypeToken, word } + @precedence { HealthToken, word } +} + diff --git a/public/app/features/alerting/unified/search/search.js b/public/app/features/alerting/unified/search/search.js new file mode 100644 index 00000000000..8ee40ac1d7c --- /dev/null +++ b/public/app/features/alerting/unified/search/search.js @@ -0,0 +1,30 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import { LRParser } from '@lezer/lr'; +export const parser = LRParser.deserialize({ + version: 14, + states: + "!vOQOPOOOrOPO'#ChOOOO'#Ch'#ChOQOPO'#ClOOOO'#Ci'#CiQQOPOOO!gOQO'#C^O!lOPO'#CjO!qOPO,59SOOOO,59W,59WOOOO-E6g-E6gOOOO,58x,58xOOOO,59U,59UOOOO-E6h-E6h", + stateData: + '$O~ORUOTUOUUOVUOWUOXUOYUOZUOaPOcQO~ObVOR[XT[XU[XV[XW[XX[XY[XZ[Xa[Xc[X~OSZO~Oa[O~ObVOR[aT[aU[aV[aW[aX[aY[aZ[aa[ac[a~OR~T~U~V~W~Y~Z~RZYXWVUTa~', + goto: 'zaPPbPPPPPPPPPbgmPsVRORTQTORYTQWPR]WSSOTRXR', + nodeNames: + '⚠ AlertRuleSearch FilterExpression DataSourceToken FilterValue NameSpaceToken LabelToken GroupToken RuleToken StateToken TypeToken HealthToken FreeFormExpression', + maxTerm: 19, + skippedNodes: [0], + repeatNodeCount: 2, + tokenData: + "#$QRRqqr#Yrs&fst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]+h!]#W#Y#W#X,z#X#Z#Y#Z#[=b#[#]F[#]#`#Y#`#a!!n#a#b#Y#b#c!+h#c#f#Y#f#g!:f#g#h!Av#h#i!Jp#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#acSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YQ$qcSQqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lQ&PP;=`<%l$lQ&VP;=`;NQ$lR&]P;=`<%l#YR&cP;=`;NQ#YR&irX^(spq(sqr(sst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR(vsX^(spq(sqr(srs+Tst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR+[OSQcPR+_P;=`<%l(sR+eP;=`;NQ(sR+ocSQbPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR-ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U.d#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR.keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i/|#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR0TeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U1f#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR1meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h3O#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR3VeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d4h#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR4oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j6Q#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR6XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#g7j#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR7qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W9S#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR9ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y:l#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR:scSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]z#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR?ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d@d#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR@keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#jA|#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRBTeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#eCf#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRCmcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]Dx!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YREPcSQVPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lRFceSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#YGt#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRG{eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#UI^#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRIeeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#aJv#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRJ}eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#iL`#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRLgeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#[#Y#[#]Mx#]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRNPcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]! [!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR! ccSQZPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!!ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!$W#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!$_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#U#Y#U#V!%p#V$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!%weSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!'Y#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!'aeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!(r#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!(ycSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!*U!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!*]cSQUPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!+oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!-Q#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!-XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#a#Y#a#b!.j#b$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!.qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!0S#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!0ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h!1l#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!1seSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e!3U#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!3]eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!4n#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!4ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W!6W#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!6_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!7p#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!7wcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!9S!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!9ZcSQTPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!:meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j! = { + [terms.DataSourceToken]: 'datasource', + [terms.NameSpaceToken]: 'namespace', + [terms.LabelToken]: 'label', + [terms.RuleToken]: 'rule', + [terms.GroupToken]: 'group', + [terms.StateToken]: 'state', + [terms.TypeToken]: 'type', + [terms.HealthToken]: 'health', +}; + +// This enum allows to configure parser behavior +// Depending on our needs we can enable and disable only selected filters +// Thanks to that we can create multiple different filters having the same search grammar +export enum FilterSupportedTerm { + dataSource = 'dataSourceFilter', + nameSpace = 'nameSpaceFilter', + label = 'labelFilter', + group = 'groupFilter', + rule = 'ruleFilter', + state = 'stateFilter', + type = 'typeFilter', + health = 'healthFilter', +} + +export type QueryFilterMapper = Record void>; + +export interface FilterExpr { + type: number; + value: string; +} + +export function parseQueryToFilter( + query: string, + supportedTerms: FilterSupportedTerm[], + filterMapper: QueryFilterMapper +) { + traverseNodeTree(query, supportedTerms, (node) => { + if (node.type.id === terms.FilterExpression) { + const filter = getFilterFromSyntaxNode(query, node); + + if (filter.type && filter.value) { + const filterHandler = filterMapper[filter.type]; + if (filterHandler) { + filterHandler(filter.value); + } + } + } else if (node.type.id === terms.FreeFormExpression) { + const filterHandler = filterMapper[terms.FreeFormExpression]; + if (filterHandler) { + filterHandler(getNodeContent(query, node)); + } + } + }); +} + +function getFilterFromSyntaxNode(query: string, filterExpressionNode: SyntaxNode): { type?: number; value?: string } { + if (filterExpressionNode.type.id !== terms.FilterExpression) { + throw new Error('Invalid node provided. Only FilterExpression nodes are supported'); + } + + const filterTokenNode = filterExpressionNode.firstChild; + if (!filterTokenNode) { + return { type: undefined, value: undefined }; + } + + const filterValueNode = filterExpressionNode.getChild(terms.FilterValue); + const filterValue = filterValueNode ? trim(getNodeContent(query, filterValueNode), '"') : undefined; + + return { type: filterTokenNode.type.id, value: filterValue }; +} + +function getNodeContent(query: string, node: SyntaxNode) { + return query.slice(node.from, node.to).trim().replace(/\"/g, ''); +} + +export function applyFiltersToQuery( + query: string, + supportedTerms: FilterSupportedTerm[], + filters: FilterExpr[] +): string { + const existingFilterNodes: SyntaxNode[] = []; + traverseNodeTree(query, supportedTerms, (node) => { + if (node.type.id === terms.FilterExpression && node.firstChild) { + existingFilterNodes.push(node.firstChild); + } + if (node.type.id === terms.FreeFormExpression) { + existingFilterNodes.push(node); + } + }); + + let newQueryExpressions: string[] = []; + + // Apply filters from filterState in the same order as they appear in the search query + // This allows to remain the order of filters in the search input during changes + existingFilterNodes.forEach((filterNode) => { + const matchingFilterIdx = filters.findIndex((f) => f.type === filterNode.type.id); + if (matchingFilterIdx === -1) { + return; + } + + if (filterNode.parent?.type.is(terms.FilterExpression)) { + const filterToken = filterTokenToTypeMap[filterNode.type.id]; + const filterItem = filters.splice(matchingFilterIdx, 1)[0]; + newQueryExpressions.push(`${filterToken}:${getSafeFilterValue(filterItem.value)}`); + } + + if (filterNode.type.is(terms.FreeFormExpression)) { + const freeFormWordNode = filters.splice(matchingFilterIdx, 1)[0]; + newQueryExpressions.push(freeFormWordNode.value); + } + }); + + // Apply new filters that hasn't been in the query yet + filters.forEach((fs) => { + if (fs.type === terms.FreeFormExpression) { + newQueryExpressions.push(fs.value); + } else { + newQueryExpressions.push(`${filterTokenToTypeMap[fs.type]}:${getSafeFilterValue(fs.value)}`); + } + }); + + return newQueryExpressions.join(' '); +} + +function traverseNodeTree(query: string, supportedTerms: FilterSupportedTerm[], visit: (node: SyntaxNode) => void) { + const dialect = supportedTerms.join(' '); + const parsed = parser.configure({ dialect }).parse(query); + let cursor = parsed.cursor(); + do { + visit(cursor.node); + } while (cursor.next()); +} + +function getSafeFilterValue(filterValue: string) { + const containsWhiteSpaces = /\s/.test(filterValue); + return containsWhiteSpaces ? `\"${filterValue}\"` : filterValue; +} diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 6386541891a..c44ba0869d4 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -27,6 +27,7 @@ import { } from 'app/types/unified-alerting-dto'; import { State } from '../components/StateTag'; +import { RuleHealth } from '../search/rulesSearchParser'; import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { AsyncRequestState } from './redux'; @@ -67,10 +68,30 @@ export function isCloudRuleIdentifier(identifier: RuleIdentifier): identifier is return 'rulerRuleHash' in identifier; } +export function isPromRuleType(ruleType: string): ruleType is PromRuleType { + return Object.values(PromRuleType).includes(ruleType); +} + export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifier is PrometheusRuleIdentifier { return 'ruleHash' in identifier; } +export function getRuleHealth(health: string): RuleHealth | undefined { + switch (health) { + case 'ok': + return RuleHealth.Ok; + case 'nodata': + return RuleHealth.NoData; + case 'error': + case 'err': // Prometheus-compat data sources + return RuleHealth.Error; + case 'unknown': + return RuleHealth.Unknown; + default: + return undefined; + } +} + export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): string { if (state === PromAlertingRuleState.Inactive) { return 'Normal'; diff --git a/public/app/features/alerting/unified/utils/search.ts b/public/app/features/alerting/unified/utils/search.ts new file mode 100644 index 00000000000..846da556b01 --- /dev/null +++ b/public/app/features/alerting/unified/utils/search.ts @@ -0,0 +1,9 @@ +import { RulesFilter } from '../search/rulesSearchParser'; + +export function getFilter(filter: Partial): RulesFilter { + return { + freeFormWords: [], + labels: [], + ...filter, + }; +} diff --git a/public/app/features/canvas/elements/icon.tsx b/public/app/features/canvas/elements/icon.tsx index cb4920e8948..8d7c8a4c876 100644 --- a/public/app/features/canvas/elements/icon.tsx +++ b/public/app/features/canvas/elements/icon.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import { isString } from 'lodash'; import React, { CSSProperties } from 'react'; -import SVG from 'react-inlinesvg'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { ColorDimensionConfig, ResourceDimensionConfig, @@ -59,7 +59,7 @@ export function IconDisplay(props: CanvasElementProps) { }; return ( - { + findElementByTarget = (target: Element): ElementState | undefined => { // We will probably want to add memoization to this as we are calling on drag / resize const stack = [...this.root.elements]; @@ -288,7 +288,7 @@ export class Scene { return undefined; }; - setNonTargetPointerEvents = (target: HTMLElement | SVGElement, disablePointerEvents: boolean) => { + setNonTargetPointerEvents = (target: Element, disablePointerEvents: boolean) => { const stack = [...this.root.elements]; while (stack.length > 0) { const currentElement = stack.shift(); diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 7ee00813631..fa6671affa3 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -6,7 +6,6 @@ import { KBarAnimator, KBarPortal, KBarPositioner, - KBarResults, KBarSearch, VisualState, useRegisterActions, @@ -16,10 +15,11 @@ import { import React, { useEffect, useMemo, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; -import { useStyles2 } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Icon, Spinner, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; +import { KBarResults } from './KBarResults'; import { ResultItem } from './ResultItem'; import { useDashboardResults } from './actions/dashboardActions'; import useActions from './actions/useActions'; @@ -34,9 +34,9 @@ export const CommandPalette = () => { searchQuery: state.searchQuery, })); - const actions = useActions(); + const actions = useActions(searchQuery); useRegisterActions(actions, [actions]); - const dashboardResults = useDashboardResults(searchQuery, showing); + const { dashboardResults, isFetchingDashboardResults } = useDashboardResults(searchQuery, showing); const ref = useRef(null); const { overlayProps } = useOverlay( @@ -56,11 +56,16 @@ export const CommandPalette = () => {
- - +
+ {isFetchingDashboardResults ? : } + +
+
+ +
@@ -92,84 +97,85 @@ const RenderResults = ({ dashboardResults }: RenderResultsProps) => { return ( { - // These items are rendered in a container, in a virtual list, so we cannot - // use :first/last-child selectors, so we must mimic them in JS - const isFirstItem = items[0] === item; - const isLastItem = items[items.length - 1] === item; + const isFirst = items[0] === item; const renderedItem = typeof item === 'string' ? ( -
-
{item}
-
+
{item}
) : ( ); - return isLastItem ?
{renderedItem}
: renderedItem; + return renderedItem; }} /> ); }; -const getSearchStyles = (theme: GrafanaTheme2) => ({ - positioner: css({ - zIndex: theme.zIndex.portal, - marginTop: '0px', - '&::before': { - content: '""', - position: 'fixed', - top: 0, - right: 0, - bottom: 0, - left: 0, - background: theme.components.overlay.background, - backdropFilter: 'blur(1px)', - }, - }), - animator: css({ - maxWidth: theme.breakpoints.values.sm, // supposed to be 600... - width: '100%', - background: theme.colors.background.canvas, - color: theme.colors.text.primary, - borderRadius: theme.shape.borderRadius(4), - overflow: 'hidden', - boxShadow: theme.shadows.z3, - }), - search: css({ - padding: theme.spacing(2, 3), - fontSize: theme.typography.fontSize, - width: '100%', - boxSizing: 'border-box', - outline: 'none', - border: 'none', - background: theme.colors.background.canvas, - color: theme.colors.text.primary, - borderBottom: `1px solid ${theme.colors.border.medium}`, - }), +const getSearchStyles = (theme: GrafanaTheme2) => { + const topNavCommandPalette = Boolean(config.featureToggles.topNavCommandPalette); - // Virtual list measures margin incorrectly, so we need to split padding before/after border - // over and inner and outer element - sectionHeader: css({ - paddingTop: theme.spacing(2), - fontSize: theme.typography.h6.fontSize, - fontWeight: theme.typography.body.fontWeight, - color: theme.colors.text.secondary, - }), - sectionHeaderInner: css({ - padding: theme.spacing(1, 2), - borderTop: `1px solid ${theme.colors.border.medium}`, - }), - - // We don't need the header above the first section - sectionHeaderInnerFirst: css({ - borderTop: 'none', - paddingTop: 0, - }), - - // Last item gets extra padding so it's not clipped by the rounded corners on the container - lastItem: css({ - paddingBottom: theme.spacing(1), - }), -}); + return { + positioner: css({ + zIndex: theme.zIndex.portal, + marginTop: '0px', + paddingTop: topNavCommandPalette ? '4px !important' : undefined, + '&::before': { + content: '""', + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, + background: theme.components.overlay.background, + backdropFilter: 'blur(1px)', + }, + }), + animator: css({ + maxWidth: theme.breakpoints.values.md, + width: '100%', + background: theme.colors.background.primary, + color: theme.colors.text.primary, + borderRadius: theme.shape.borderRadius(2), + border: `1px solid ${theme.colors.border.weak}`, + overflow: 'hidden', + boxShadow: theme.shadows.z3, + }), + searchContainer: css({ + alignItems: 'center', + background: theme.components.input.background, + borderBottom: `1px solid ${theme.colors.border.weak}`, + display: 'flex', + gap: theme.spacing(1), + padding: theme.spacing(1, 2), + }), + search: css({ + fontSize: theme.typography.fontSize, + width: '100%', + boxSizing: 'border-box', + outline: 'none', + border: 'none', + color: theme.components.input.text, + }), + spinner: css({ + height: '22px', + }), + resultsContainer: css({ + paddingBottom: theme.spacing(1), + }), + sectionHeader: css({ + padding: theme.spacing(1.5, 2, 1, 2), + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.primary, + borderTop: `1px solid ${theme.colors.border.weak}`, + marginTop: theme.spacing(1), + }), + sectionHeaderFirst: css({ + borderTop: 'none', + marginTop: 0, + }), + }; +}; diff --git a/public/app/features/commandPalette/KBarResults.tsx b/public/app/features/commandPalette/KBarResults.tsx new file mode 100644 index 00000000000..76ff919b1f1 --- /dev/null +++ b/public/app/features/commandPalette/KBarResults.tsx @@ -0,0 +1,226 @@ +import { ActionImpl, getListboxItemId, KBAR_LISTBOX, useKBar } from 'kbar'; +import { usePointerMovedSinceMount } from 'kbar/lib/utils'; +import * as React from 'react'; +import { useVirtual } from 'react-virtual'; + +// From https://github.com/timc1/kbar/blob/main/src/KBarResults.tsx +// TODO: Go back to KBarResults from kbar when https://github.com/timc1/kbar/issues/281 is fixed +// Remember to remove dependency on react-virtual when removing this file + +const START_INDEX = 0; + +interface RenderParams { + item: T; + active: boolean; +} + +interface KBarResultsProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: any[]; + onRender: (params: RenderParams) => React.ReactElement; + maxHeight?: number; +} + +export const KBarResults: React.FC = (props) => { + const activeRef = React.useRef(null); + const parentRef = React.useRef(null); + + // store a ref to all items so we do not have to pass + // them as a dependency when setting up event listeners. + const itemsRef = React.useRef(props.items); + itemsRef.current = props.items; + + const rowVirtualizer = useVirtual({ + size: itemsRef.current.length, + parentRef, + }); + + const { query, search, currentRootActionId, activeIndex, options } = useKBar((state) => ({ + search: state.searchQuery, + currentRootActionId: state.currentRootActionId, + activeIndex: state.activeIndex, + })); + + React.useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp' || (event.ctrlKey && event.key === 'p')) { + event.preventDefault(); + query.setActiveIndex((index) => { + let nextIndex = index > START_INDEX ? index - 1 : index; + // avoid setting active index on a group + if (typeof itemsRef.current[nextIndex] === 'string') { + if (nextIndex === 0) { + return index; + } + nextIndex -= 1; + } + return nextIndex; + }); + } else if (event.key === 'ArrowDown' || (event.ctrlKey && event.key === 'n')) { + event.preventDefault(); + query.setActiveIndex((index) => { + let nextIndex = index < itemsRef.current.length - 1 ? index + 1 : index; + // avoid setting active index on a group + if (typeof itemsRef.current[nextIndex] === 'string') { + if (nextIndex === itemsRef.current.length - 1) { + return index; + } + nextIndex += 1; + } + return nextIndex; + }); + } else if (event.key === 'Enter') { + event.preventDefault(); + // storing the active dom element in a ref prevents us from + // having to calculate the current action to perform based + // on the `activeIndex`, which we would have needed to add + // as part of the dependencies array. + activeRef.current?.click(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [query]); + + // destructuring here to prevent linter warning to pass + // entire rowVirtualizer in the dependencies array. + const { scrollToIndex } = rowVirtualizer; + React.useEffect(() => { + scrollToIndex(activeIndex, { + // ensure that if the first item in the list is a group + // name and we are focused on the second item, to not + // scroll past that group, hiding it. + align: activeIndex <= 1 ? 'end' : 'auto', + }); + }, [activeIndex, scrollToIndex]); + + React.useEffect(() => { + // TODO(tim): fix scenario where async actions load in + // and active index is reset to the first item. i.e. when + // users register actions and bust the `useRegisterActions` + // cache, we won't want to reset their active index as they + // are navigating the list. + query.setActiveIndex( + // avoid setting active index on a group + typeof props.items[START_INDEX] === 'string' ? START_INDEX + 1 : START_INDEX + ); + }, [search, currentRootActionId, props.items, query]); + + const execute = React.useCallback( + (ev: React.MouseEvent, item: RenderParams['item']) => { + if (typeof item === 'string') { + return; + } + + // ActionImpl constructor copies all properties from action onto ActionImpl + // so our url property is secretly there, but completely untyped + // Preferably this change is upstreamed and ActionImpl has this + // eslint-disable-next-line + const url = (item as ActionImpl & { url?: string }).url; + + if (item.command) { + item.command.perform(item); + query.toggle(); + } else if (url) { + if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) { + query.toggle(); + } + } else { + query.setSearch(''); + query.setCurrentRootAction(item.id); + } + + options.callbacks?.onSelectAction?.(item); + }, + [query, options] + ); + + const pointerMoved = usePointerMovedSinceMount(); + + return ( +
+
+ {rowVirtualizer.virtualItems.map((virtualRow) => { + const item = itemsRef.current[virtualRow.index]; + + // ActionImpl constructor copies all properties from action onto ActionImpl + // so our url property is secretly there, but completely untyped + // Preferably this change is upstreamed and ActionImpl has this + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const url = (item as ActionImpl & { url?: string }).url; + + const handlers = typeof item !== 'string' && { + onPointerMove: () => + pointerMoved && activeIndex !== virtualRow.index && query.setActiveIndex(virtualRow.index), + onPointerDown: () => query.setActiveIndex(virtualRow.index), + onClick: (ev: React.MouseEvent) => execute(ev, item), + }; + const active = virtualRow.index === activeIndex; + + const childProps = { + id: getListboxItemId(virtualRow.index), + role: 'option', + 'aria-selected': active, + style: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + transform: `translateY(${virtualRow.start}px)`, + } as const, + ...handlers, + }; + + const renderedItem = React.cloneElement( + props.onRender({ + item, + active, + }), + { + ref: virtualRow.measureRef, + } + ); + + if (url) { + return ( + ) : null} + {...childProps} + > + {renderedItem} + + ); + } + + return ( +
) : null} + {...childProps} + > + {renderedItem} +
+ ); + })} +
+
+ ); +}; diff --git a/public/app/features/commandPalette/ResultItem.tsx b/public/app/features/commandPalette/ResultItem.tsx index fc4858fa00e..1102f5aa929 100644 --- a/public/app/features/commandPalette/ResultItem.tsx +++ b/public/app/features/commandPalette/ResultItem.tsx @@ -1,9 +1,9 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { ActionId, ActionImpl } from 'kbar'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; export const ResultItem = React.forwardRef( ( @@ -31,18 +31,20 @@ export const ResultItem = React.forwardRef( return action.ancestors.slice(index + 1); }, [action.ancestors, currentRootActionId]); - const theme = useTheme2(); - const styles = getResultItemStyles(theme, active); + const styles = useStyles2(getResultItemStyles); let name = action.name; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const hasAction = Boolean(action.command?.perform || (action as ActionImpl & { url?: string }).url); + // TODO: does this needs adjusting for i18n? - if (action.children && !action.command?.perform && !name.endsWith('...')) { + if (action.children.length && !hasAction && !name.endsWith('...')) { name += '...'; } return ( -
+
{action.icon}
@@ -63,31 +65,33 @@ export const ResultItem = React.forwardRef( ); } ); + ResultItem.displayName = 'ResultItem'; -const getResultItemStyles = (theme: GrafanaTheme2, isActive: boolean) => { - const textColor = isActive ? theme.colors.text.maxContrast : theme.colors.text.primary; - const rowBackgroundColor = isActive ? theme.colors.background.primary : 'transparent'; - const shortcutBackgroundColor = isActive ? theme.colors.background.secondary : theme.colors.background.primary; +const getResultItemStyles = (theme: GrafanaTheme2) => { return { row: css({ - color: textColor, padding: theme.spacing(1, 2), - background: rowBackgroundColor, display: 'flex', alightItems: 'center', justifyContent: 'space-between', cursor: 'pointer', position: 'relative', + borderRadius: theme.shape.borderRadius(2), + margin: theme.spacing(0, 1), + }), + activeRow: css({ + color: theme.colors.text.maxContrast, + background: theme.colors.emphasize(theme.colors.background.primary, 0.03), '&:before': { - display: isActive ? 'block' : 'none', + display: 'block', content: '" "', position: 'absolute', left: 0, top: 0, bottom: 0, width: theme.spacing(0.5), - borderRadius: theme.shape.borderRadius(1), + borderRadius: theme.shape.borderRadius(2), backgroundImage: theme.colors.gradients.brandVertical, }, }), @@ -103,7 +107,7 @@ const getResultItemStyles = (theme: GrafanaTheme2, isActive: boolean) => { }), shortcut: css({ padding: theme.spacing(0, 1), - background: shortcutBackgroundColor, + background: theme.colors.background.secondary, borderRadius: theme.shape.borderRadius(), fontSize: theme.typography.fontSize, }), diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts index 8c8647293cd..76ff3e60bb1 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.ts @@ -2,7 +2,6 @@ import debounce from 'debounce-promise'; import { useEffect, useState } from 'react'; import { locationUtil } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; import { t } from 'app/core/internationalization'; import impressionSrv from 'app/core/services/impression_srv'; import { getGrafanaSearcher } from 'app/features/search/service'; @@ -34,13 +33,11 @@ export async function getRecentDashboardActions(): Promise { const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure return { - id: `recent-dashboards/${url}`, + id: `recent-dashboards${url}`, name: `${name}`, section: t('command-palette.section.recent-dashboards', 'Recent dashboards'), priority: RECENT_DASHBOARDS_PRORITY, - perform: () => { - locationService.push(locationUtil.stripBaseFromUrl(url)); - }, + url: locationUtil.stripBaseFromUrl(url), }; }); @@ -62,13 +59,11 @@ export async function getDashboardSearchResultActions(searchQuery: string): Prom const goToDashboardActions: CommandPaletteAction[] = data.view.map((item) => { const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure return { - id: `go/dashboard/${url}`, + id: `go/dashboard${url}`, name: `${name}`, section: t('command-palette.section.dashboard-search-results', 'Dashboards'), priority: SEARCH_RESULTS_PRORITY, - perform: () => { - locationService.push(locationUtil.stripBaseFromUrl(url)); - }, + url: locationUtil.stripBaseFromUrl(url), }; }); @@ -77,15 +72,23 @@ export async function getDashboardSearchResultActions(searchQuery: string): Prom export function useDashboardResults(searchQuery: string, isShowing: boolean) { const [dashboardResults, setDashboardResults] = useState([]); + const [isFetchingDashboardResults, setIsFetchingDashboardResults] = useState(false); // Hit dashboards API useEffect(() => { if (isShowing && searchQuery.length > 0) { + setIsFetchingDashboardResults(true); debouncedDashboardSearch(searchQuery).then((resultActions) => { setDashboardResults(resultActions); + setIsFetchingDashboardResults(false); }); + } else { + setDashboardResults([]); } }, [isShowing, searchQuery]); - return dashboardResults; + return { + dashboardResults, + isFetchingDashboardResults, + }; } diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 835cec7be4f..0914c5d4435 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -1,5 +1,5 @@ import { locationUtil, NavModelItem } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { t } from 'app/core/internationalization'; import { changeTheme } from 'app/core/services/theme'; @@ -31,7 +31,7 @@ function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Comma id: idForNavItem(navItem), name: text, // TODO: translate section: section, - perform: url ? () => locationService.push(locationUtil.stripBaseFromUrl(url)) : undefined, + url: url && locationUtil.stripBaseFromUrl(url), parent: parent && idForNavItem(parent), priority: DEFAULT_PRIORITY, }; @@ -49,14 +49,6 @@ function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Comma export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => { const globalActions: CommandPaletteAction[] = [ - { - id: 'go/search', - name: t('command-palette.action.search', 'Search'), - keywords: 'navigate', - perform: () => locationService.push('?search=open'), - section: t('command-palette.section.pages', 'Pages'), - priority: DEFAULT_PRIORITY, - }, { id: 'preferences/theme', name: t('command-palette.action.change-theme', 'Change theme...'), @@ -82,6 +74,17 @@ export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => { }, ]; + if (!config.featureToggles.topNavCommandPalette) { + globalActions.unshift({ + id: 'go/search', + name: t('command-palette.action.search', 'Search'), + keywords: 'navigate', + perform: () => locationService.push('?search=open'), + section: t('command-palette.section.pages', 'Pages'), + priority: DEFAULT_PRIORITY, + }); + } + const navBarActions = navTreeToActions(navBarTree); return [...globalActions, ...navBarActions]; diff --git a/public/app/features/commandPalette/actions/useActions.ts b/public/app/features/commandPalette/actions/useActions.ts index 7dc92ff601d..15f5547266f 100644 --- a/public/app/features/commandPalette/actions/useActions.ts +++ b/public/app/features/commandPalette/actions/useActions.ts @@ -7,29 +7,33 @@ import { CommandPaletteAction } from '../types'; import { getRecentDashboardActions } from './dashboardActions'; import getStaticActions from './staticActions'; -export default function useActions() { - const [staticActions, setStaticActions] = useState([]); +export default function useActions(searchQuery: string) { + const [navTreeActions, setNavTreeActions] = useState([]); + const [recentDashboardActions, setRecentDashboardActions] = useState([]); const { navBarTree } = useSelector((state) => { return { navBarTree: state.navBarTree, }; }); - // Load standard static actions useEffect(() => { const staticActionsResp = getStaticActions(navBarTree); - setStaticActions(staticActionsResp); + setNavTreeActions(staticActionsResp); }, [navBarTree]); // Load recent dashboards - we don't want them to reload when the nav tree changes useEffect(() => { - getRecentDashboardActions() - .then((recentDashboardActions) => setStaticActions((v) => [...v, ...recentDashboardActions])) - .catch((err) => { - console.error('Error loading recent dashboard actions', err); - }); - }, []); + if (!searchQuery) { + getRecentDashboardActions() + .then((recentDashboardActions) => setRecentDashboardActions(recentDashboardActions)) + .catch((err) => { + console.error('Error loading recent dashboard actions', err); + }); + } else { + setRecentDashboardActions([]); + } + }, [searchQuery]); - return staticActions; + return [...recentDashboardActions, ...navTreeActions]; } diff --git a/public/app/features/commandPalette/types.ts b/public/app/features/commandPalette/types.ts index 01c1690025a..d44879d69cd 100644 --- a/public/app/features/commandPalette/types.ts +++ b/public/app/features/commandPalette/types.ts @@ -9,10 +9,11 @@ export type CommandPaletteAction = RootCommandPaletteAction | ChildCommandPalett type RootCommandPaletteAction = Omit & { section: NotNullable; priority: NotNullable; + url?: string; }; type ChildCommandPaletteAction = Action & { parent: NotNullable; - priority: NotNullable; + url?: string; }; diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx index 9d30b556fb4..923efa7eb4b 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx @@ -8,7 +8,6 @@ import { hasChanges, ignoreChanges } from './DashboardPrompt'; function getDefaultDashboardModel() { return createDashboardModelFixture({ - refresh: false, panels: [ createPanelJSONFixture({ id: 1, @@ -56,7 +55,7 @@ describe('DashboardPrompt', () => { it('Should ignore a lot of changes', () => { const { original, dash } = getTestContext(); dash.time = { from: '1h' }; - dash.refresh = true; + dash.refresh = '30s'; dash.schemaVersion = 10; expect(hasChanges(dash, original)).toBe(false); }); diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx index 42133dd7976..fc3e87a2f30 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx @@ -178,7 +178,7 @@ function cleanDashboardFromIgnoredChanges(dashData: Dashboard) { // ignore time and refresh dash.time = 0; - dash.refresh = 0; + dash.refresh = ''; dash.schemaVersion = 0; dash.timezone = 0; diff --git a/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx index 69b004a959f..4b65096307d 100644 --- a/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx +++ b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx @@ -17,11 +17,9 @@ export const PublicDashboardFooter = function () { return conf.hide ? null : ( ); }; @@ -41,10 +39,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: flex; justify-content: end; height: 30px; - padding: ${theme.spacing(0, 1, 0, 1)}; + padding: ${theme.spacing(0, 2, 0, 1)}; `, - logoText: css` - margin-right: ${theme.spacing(1)}; + link: css` + display: flex; + gap: 4px; + justify-content: end; + align-items: center; `, logoImg: css` height: 100%; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 8064761de1e..357effdee2f 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -27,22 +27,11 @@ export function addPanelShareTab(tab: ShareModalTabModel) { customPanelTabs.push(tab); } -function getInitialState(props: Props): State { - const { tabs, activeTab } = getTabs(props); - - return { - tabs, - activeTab, - }; -} - -function getTabs(props: Props) { - const { panel, activeTab } = props; - +function getTabs(panel?: PanelModel, activeTab?: string) { const linkLabel = t('share-modal.tab-title.link', 'Link'); const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }]; - if (contextSrv.isSignedIn) { + if (contextSrv.isSignedIn && config.snapshotEnabled) { const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot'); tabs.push({ label: snapshotLabel, value: 'snapshot', component: ShareSnapshot }); } @@ -87,6 +76,15 @@ interface State { activeTab: string; } +function getInitialState(props: Props): State { + const { tabs, activeTab } = getTabs(props.panel, props.activeTab); + + return { + tabs, + activeTab, + }; +} + export class ShareModal extends React.Component { constructor(props: Props) { super(props); @@ -98,13 +96,9 @@ export class ShareModal extends React.Component { } onSelectTab = (t: any) => { - this.setState({ activeTab: t.value }); + this.setState((prevState) => ({ ...prevState, activeTab: t.value })); }; - getTabs() { - return getTabs(this.props).tabs; - } - getActiveTab() { const { tabs, activeTab } = this.state; return tabs.find((t) => t.value === activeTab)!; @@ -114,12 +108,13 @@ export class ShareModal extends React.Component { const { panel } = this.props; const { activeTab } = this.state; const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); + const tabs = getTabs(this.props.panel, this.state.activeTab).tabs; return ( diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 7f17338f7db..7091793c186 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -75,6 +75,7 @@ beforeEach(() => { config.featureToggles.publicDashboards = true; mockDashboard = createDashboardModelFixture({ uid: 'mockDashboardUid', + timezone: 'utc', }); mockPanel = new PanelModel({ @@ -172,7 +173,7 @@ describe('SharePublic', () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); await screen.findByText('Welcome to Grafana public dashboards alpha!'); - expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); + expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 00:59:59')).toBeInTheDocument(); }); it('when modal is opened, then loader spinner appears and inputs are disabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 32a0e5f9bc5..f34e450863c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -63,10 +63,6 @@ jest.mock('react-virtualized-auto-sizer', () => { return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); }); -// the mock below gets rid of this warning from recompose: -// Warning: React.createFactory() is deprecated and will be removed in a future major release. Consider using JSX or use React.createElement() directly instead. -jest.mock('@jaegertracing/jaeger-ui-components', () => ({})); - interface ScenarioContext { dashboard?: DashboardModel | null; container?: HTMLElement; diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx index d517a64c403..f012e8886af 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -93,6 +93,7 @@ const getTestDashboard = (overrides?: Partial, metaOverrides?: Partia schemaVersion: 1, style: 'dark', timepicker: { hidden: true }, + timezone: '', panels: [ { id: 1, diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 3801ffc85e5..30f52049bb0 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -86,7 +86,7 @@ export class DashboardModel implements TimeModel { templating: { list: any[] }; private originalTemplating: any; annotations: { list: AnnotationQuery[] }; - refresh: any; + refresh: string; snapshot: any; schemaVersion: number; version: number; @@ -145,7 +145,7 @@ export class DashboardModel implements TimeModel { this.liveNow = Boolean(data.liveNow); this.templating = this.ensureListExist(data.templating); this.annotations = this.ensureListExist(data.annotations); - this.refresh = data.refresh; + this.refresh = data.refresh || ''; this.snapshot = data.snapshot; this.schemaVersion = data.schemaVersion ?? 0; this.fiscalYearStartMonth = data.fiscalYearStartMonth ?? 0; diff --git a/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts b/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts index 147f51aa03d..20cc4af46e1 100644 --- a/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts +++ b/public/app/features/dashboard/state/__fixtures__/dashboardFixtures.ts @@ -24,6 +24,7 @@ export function createDashboardModelFixture( schemaVersion: 1, revision: 1, style: 'dark', + timezone: '', ...dashboardInput, }; diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts index 81e8cb6bf1c..5d06e3fb211 100644 --- a/public/app/features/datasources/state/actions.test.ts +++ b/public/app/features/datasources/state/actions.test.ts @@ -6,6 +6,7 @@ import { ThunkResult, ThunkDispatch } from 'app/types'; import { getMockDataSource } from '../__mocks__'; import * as api from '../api'; +import { DATASOURCES_ROUTES } from '../constants'; import { trackDataSourceCreated, trackDataSourceTested } from '../tracking'; import { GenericDataSourcePlugin } from '../types'; @@ -357,6 +358,7 @@ describe('addDataSource', () => { plugin_version: '1.2.3', datasource_uid: 'azure23', grafana_version: '1.0', + editLink: DATASOURCES_ROUTES.Edit.replace(':uid', 'azure23'), }); }); }); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 0ce72e58b28..eb8a772ac36 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -189,7 +189,7 @@ export function loadDataSourceMeta(dataSource: DataSourceSettings): ThunkResult< }; } -export function addDataSource(plugin: DataSourcePluginMeta, editLink = DATASOURCES_ROUTES.Edit): ThunkResult { +export function addDataSource(plugin: DataSourcePluginMeta, editRoute = DATASOURCES_ROUTES.Edit): ThunkResult { return async (dispatch, getStore) => { await dispatch(loadDataSources()); @@ -207,6 +207,7 @@ export function addDataSource(plugin: DataSourcePluginMeta, editLink = DATASOURC } const result = await api.createDataSource(newInstance); + const editLink = editRoute.replace(/:uid/gi, result.datasource.uid); await getDatasourceSrv().reload(); await contextSrv.fetchUserPermissions(); @@ -216,9 +217,10 @@ export function addDataSource(plugin: DataSourcePluginMeta, editLink = DATASOURC plugin_id: plugin.id, datasource_uid: result.datasource.uid, plugin_version: result.meta?.info?.version, + editLink, }); - locationService.push(editLink.replace(/:uid/gi, result.datasource.uid)); + locationService.push(editLink); }; } diff --git a/public/app/features/datasources/tracking.ts b/public/app/features/datasources/tracking.ts index 8323f5d1324..5de67f15209 100644 --- a/public/app/features/datasources/tracking.ts +++ b/public/app/features/datasources/tracking.ts @@ -24,6 +24,8 @@ type DataSourceCreatedProps = { plugin_id: string; /** The plugin version (especially interesting in external plugins - core plugins are aligned with grafana version) */ plugin_version?: string; + /** The URL that points to the edit page for the datasoruce. We are using this to be able to distinguish between the performance of different datasource edit locations. */ + editLink?: string; }; /** diff --git a/public/app/features/dimensions/editors/FileUploader.tsx b/public/app/features/dimensions/editors/FileUploader.tsx index 9a8cf21712d..7e58eaf4617 100644 --- a/public/app/features/dimensions/editors/FileUploader.tsx +++ b/public/app/features/dimensions/editors/FileUploader.tsx @@ -1,11 +1,12 @@ import { css } from '@emotion/css'; import React, { Dispatch, SetStateAction, useState } from 'react'; -import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; import { FileDropzone, useStyles2, Button, DropzoneFile, Field } from '@grafana/ui'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { MediaType } from '../types'; + interface Props { setFormData: Dispatch>; mediaType: MediaType; @@ -36,7 +37,7 @@ export const FileUploader = ({ mediaType, setFormData, setUpload, error }: Props const Preview = () => (
- {mediaType === MediaType.Icon && } + {mediaType === MediaType.Icon && } {mediaType === MediaType.Image && Preview of the uploaded file}
diff --git a/public/app/features/dimensions/editors/ResourceCards.tsx b/public/app/features/dimensions/editors/ResourceCards.tsx index 49aa6ea804c..57d01604cda 100644 --- a/public/app/features/dimensions/editors/ResourceCards.tsx +++ b/public/app/features/dimensions/editors/ResourceCards.tsx @@ -1,11 +1,11 @@ import { css, cx } from '@emotion/css'; import React, { memo, CSSProperties } from 'react'; -import SVG from 'react-inlinesvg'; import AutoSizer from 'react-virtualized-auto-sizer'; import { areEqual, FixedSizeGrid as Grid } from 'react-window'; import { GrafanaTheme2 } from '@grafana/data'; import { useTheme2, stylesFactory } from '@grafana/ui'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { ResourceItem } from './FolderPickerTab'; @@ -38,7 +38,7 @@ function Cell(props: CellProps) { onClick={() => onChange(card.value)} > {card.imgUrl.endsWith('.svg') ? ( - + ) : ( )} diff --git a/public/app/features/dimensions/editors/ResourcePicker.tsx b/public/app/features/dimensions/editors/ResourcePicker.tsx index 30246d6aa0d..ecfb63f3ef4 100644 --- a/public/app/features/dimensions/editors/ResourcePicker.tsx +++ b/public/app/features/dimensions/editors/ResourcePicker.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import React, { createRef } from 'react'; -import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; import { @@ -15,6 +14,7 @@ import { useTheme2, } from '@grafana/ui'; import { closePopover } from '@grafana/ui/src/utils/closePopover'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { getPublicOrAbsoluteUrl } from '../resource'; import { MediaType, ResourceFolderName, ResourcePickerSize } from '../types'; @@ -56,7 +56,7 @@ export const ResourcePicker = (props: Props) => { const renderSmallResourcePicker = () => { if (value && sanitizedSrc) { - return ; + return ; } else { return ( @@ -73,7 +73,7 @@ export const ResourcePicker = (props: Props) => { value={name} placeholder={placeholder} readOnly={true} - prefix={sanitizedSrc && } + prefix={sanitizedSrc && } suffix={ + ); + }; + let LogsSamplePanelContent: JSX.Element | null; if (queryResponse === undefined) { @@ -46,16 +93,19 @@ export function LogsSamplePanel(props: Props) { } else { const logs = dataFrameToLogsModel(queryResponse.data); LogsSamplePanelContent = ( - + <> + + + ); } @@ -65,3 +115,11 @@ export function LogsSamplePanel(props: Props) { ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + logSamplesButton: css` + position: absolute; + top: ${theme.spacing(1)}; + right: ${theme.spacing(1)}; ; + `, +}); diff --git a/public/app/features/explore/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraphContainer.tsx index 868b77d1df4..2594f757a13 100644 --- a/public/app/features/explore/NodeGraphContainer.tsx +++ b/public/app/features/explore/NodeGraphContainer.tsx @@ -4,7 +4,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { useToggle, useWindowSize } from 'react-use'; import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Collapse, useStyles2, useTheme2 } from '@grafana/ui'; import { NodeGraph } from '../../plugins/panel/nodeGraph'; @@ -59,6 +59,7 @@ export function UnconnectedNodeGraphContainer(props: Props) { toggleOpen(); reportInteraction('grafana_traces_node_graph_panel_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, isExpanded: !open, }); }; diff --git a/public/app/features/explore/PrometheusListView/ItemLabels.tsx b/public/app/features/explore/PrometheusListView/ItemLabels.tsx index 40fb7ba9712..b5db12afe93 100644 --- a/public/app/features/explore/PrometheusListView/ItemLabels.tsx +++ b/public/app/features/explore/PrometheusListView/ItemLabels.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { Field, GrafanaTheme2 } from '@grafana/data/'; import { useStyles2 } from '@grafana/ui/'; +import { InstantQueryRefIdIndex } from '../../../plugins/datasource/prometheus/datasource'; + import { rawListItemColumnWidth } from './RawListItem'; const getItemLabelsStyles = (theme: GrafanaTheme2, expanded: boolean) => { @@ -22,14 +24,22 @@ const getItemLabelsStyles = (theme: GrafanaTheme2, expanded: boolean) => { }; }; +const formatValueName = (name: string): string => { + if (name.includes(InstantQueryRefIdIndex)) { + return name.replace(InstantQueryRefIdIndex, ''); + } + return name; +}; + export const ItemLabels = ({ valueLabels, expanded }: { valueLabels: Field[]; expanded: boolean }) => { const styles = useStyles2((theme) => getItemLabelsStyles(theme, expanded)); + return (
{valueLabels.map((value, index) => ( - {value.name} + {formatValueName(value.name)} ))}
diff --git a/public/app/features/explore/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index 786bd98b09e..22983d9f0c9 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -2,8 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; -import { DataQuery } from '@grafana/data'; import { setDataSourceSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { configureStore } from 'app/store/configureStore'; import { ExploreId, ExploreState } from 'app/types'; diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index a1b7b0ce782..da17315b973 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,8 +1,9 @@ import { createSelector } from '@reduxjs/toolkit'; import React, { useCallback, useMemo } from 'react'; -import { CoreApp, DataQuery, DataSourceInstanceSettings } from '@grafana/data'; +import { CoreApp } from '@grafana/data'; import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { useDispatch, useSelector } from 'app/types'; import { ExploreId } from 'app/types/explore'; @@ -49,9 +50,19 @@ export const QueryRows = ({ exploreId }: Props) => { }, [dispatch, exploreId]); const onChange = useCallback( - (newQueries: DataQuery[]) => { + async (newQueries: DataQuery[]) => { dispatch(changeQueriesAction({ queries: newQueries, exploreId })); + for (const newQuery of newQueries) { + for (const oldQuery of queries) { + if (newQuery.refId === oldQuery.refId && newQuery.datasource?.type !== oldQuery.datasource?.type) { + const queryDatasource = await getDataSourceSrv().get(newQuery.datasource); + const targetDS = await getDataSourceSrv().get({ uid: newQuery.datasource?.uid }); + dispatch(importQueries(exploreId, queries, queryDatasource, targetDS, newQuery.refId)); + } + } + } + // if we are removing a query we want to run the remaining ones if (newQueries.length < queries.length) { onRunQueries(); @@ -67,12 +78,6 @@ export const QueryRows = ({ exploreId }: Props) => { [onChange, queries] ); - const onMixedDataSourceChange = async (ds: DataSourceInstanceSettings, query: DataQuery) => { - const queryDatasource = await getDataSourceSrv().get(query.datasource); - const targetDS = await getDataSourceSrv().get({ uid: ds.uid }); - dispatch(importQueries(exploreId, queries, queryDatasource, targetDS, query.refId)); - }; - const onQueryCopied = () => { reportInteraction('grafana_explore_query_row_copy'); }; @@ -88,7 +93,6 @@ export const QueryRows = ({ exploreId }: Props) => { return ( onMixedDataSourceChange(ds, query)} queries={queries} onQueriesChange={onChange} onAddQuery={onAddQuery} diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx index 7a450f373a0..15b1f4ba4ff 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent, getByText } from '@testing-library/react'; import React from 'react'; -import { DataSourceApi, DataQuery } from '@grafana/data'; +import { DataSourceApi } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 8a84806db18..ccf5e9eee6d 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -2,8 +2,9 @@ import { css, cx } from '@emotion/css'; import React, { useState, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { DataSourceApi, DataQuery, GrafanaTheme2 } from '@grafana/data'; +import { DataSourceApi, GrafanaTheme2 } from '@grafana/data'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import appEvents from 'app/core/app_events'; diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 89e1b7adc82..da19941910e 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -1,5 +1,3 @@ -import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView'; -import { TraceData, TraceSpanData } from '@jaegertracing/jaeger-ui-components/src/types/trace'; import { render, prettyDOM, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { createRef } from 'react'; @@ -12,6 +10,8 @@ import { ExploreId } from 'app/types'; import { configureStore } from '../../../store/configureStore'; import { TraceView } from './TraceView'; +import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView'; +import { TraceData, TraceSpanData } from './components/types/trace'; import { transformDataFrames } from './utils/transform'; function getTraceView(frames: DataFrame[]) { @@ -23,7 +23,7 @@ function getTraceView(frames: DataFrame[]) { }; const topOfViewRef = createRef(); - const traceView = ( + return ( ); - return traceView; } function renderTraceView(frames = [frameOld]) { diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index b0a33db5103..11badf9eca6 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -1,11 +1,9 @@ import { css } from '@emotion/css'; -import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView'; import React, { RefObject, useCallback, useMemo, useState } from 'react'; import { DataFrame, DataLink, - DataQuery, DataSourceApi, DataSourceJsonData, Field, @@ -16,15 +14,9 @@ import { SplitOpen, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; -import { - SpanBarOptionsData, - Trace, - TracePageHeader, - TraceTimelineViewer, - TTraceTimeline, -} from '@jaegertracing/jaeger-ui-components'; -import { TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; +import { getTraceToLogsOptions, TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsData } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getTimeZone } from 'app/features/profile/state/selectors'; @@ -34,6 +26,8 @@ import { ExploreId } from 'app/types/explore'; import { changePanelState } from '../state/explorePane'; +import { SpanBarOptionsData, Trace, TracePageHeader, TraceTimelineViewer, TTraceTimeline } from './components'; +import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView'; import { createSpanLinkFactory } from './createSpanLink'; import { useChildrenState } from './useChildrenState'; import { useDetailState } from './useDetailState'; @@ -121,7 +115,7 @@ export function TraceView(props: Props) { ); const instanceSettings = getDatasourceSrv().getInstanceSettings(datasource?.name); - const traceToLogsOptions = (instanceSettings?.jsonData as TraceToLogsData)?.tracesToLogs; + const traceToLogsOptions = getTraceToLogsOptions(instanceSettings?.jsonData as TraceToLogsData); const traceToMetricsOptions = (instanceSettings?.jsonData as TraceToMetricsData)?.tracesToMetrics; const spanBarOptions: SpanBarOptionsData | undefined = instanceSettings?.jsonData; @@ -133,8 +127,9 @@ export function TraceView(props: Props) { traceToMetricsOptions, dataFrame: props.dataFrames[0], createFocusSpanLink, + trace: traceProp, }), - [props.splitOpenFn, traceToLogsOptions, traceToMetricsOptions, props.dataFrames, createFocusSpanLink] + [props.splitOpenFn, traceToLogsOptions, traceToMetricsOptions, props.dataFrames, createFocusSpanLink, traceProp] ); const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]); const timeZone = useSelector((state) => getTimeZone(state.user)); diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 1d908a0ed64..d0fad09498e 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -1,5 +1,3 @@ -import TracePageSearchBar from '@jaegertracing/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar'; -import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView'; import React, { RefObject, useMemo, useState } from 'react'; import { DataFrame, SplitOpen, PanelData } from '@grafana/data'; @@ -8,6 +6,8 @@ import { StoreState, useSelector } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { TraceView } from './TraceView'; +import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar'; +import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView'; import { useSearch } from './useSearch'; import { transformDataFrames } from './utils/transform'; interface Props { diff --git a/packages/jaeger-ui-components/LICENSE_APACHE2 b/public/app/features/explore/TraceView/components/LICENSE_APACHE2 similarity index 100% rename from packages/jaeger-ui-components/LICENSE_APACHE2 rename to public/app/features/explore/TraceView/components/LICENSE_APACHE2 diff --git a/packages/jaeger-ui-components/src/ScrollManager.test.ts b/public/app/features/explore/TraceView/components/ScrollManager.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/ScrollManager.test.ts rename to public/app/features/explore/TraceView/components/ScrollManager.test.ts index 7f7e1368279..3be8a64327e 100644 --- a/packages/jaeger-ui-components/src/ScrollManager.test.ts +++ b/public/app/features/explore/TraceView/components/ScrollManager.test.ts @@ -14,9 +14,8 @@ jest.mock('./scroll-page'); -import traceGenerator from '../src/demo/trace-generators'; - import ScrollManager, { Accessors } from './ScrollManager'; +import traceGenerator from './demo/trace-generators'; import { scrollBy, scrollTo } from './scroll-page'; import { Trace, TraceSpanData, TraceSpanReference } from './types/trace'; diff --git a/packages/jaeger-ui-components/src/ScrollManager.tsx b/public/app/features/explore/TraceView/components/ScrollManager.tsx similarity index 100% rename from packages/jaeger-ui-components/src/ScrollManager.tsx rename to public/app/features/explore/TraceView/components/ScrollManager.tsx diff --git a/packages/jaeger-ui-components/src/Theme.tsx b/public/app/features/explore/TraceView/components/Theme.tsx similarity index 100% rename from packages/jaeger-ui-components/src/Theme.tsx rename to public/app/features/explore/TraceView/components/Theme.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx index eb2277c1b3d..a8a12ac1e64 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx @@ -19,8 +19,8 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { withTheme2, stylesFactory, Button } from '@grafana/ui'; -import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate, TNil } from '../..'; import { autoColor } from '../../Theme'; +import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate, TNil } from '../../index'; import DraggableManager, { DraggableBounds, DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager'; import GraphTicks from './GraphTicks'; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx similarity index 97% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx index 3a1a0275e72..f7c05fa723a 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/index.tsx @@ -16,8 +16,8 @@ import cx from 'classnames'; import memoizeOne from 'memoize-one'; import * as React from 'react'; -import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../..'; -import { TraceSpan, Trace } from '../../types/trace'; +import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../../index'; +import { TraceSpan, Trace } from '../../types'; import { ubPb2, ubPx2, ubRelative } from '../../uberUtilityStyles'; import CanvasSpanGraph from './CanvasSpanGraph'; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.ts b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.ts rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.test.ts diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.tsx similarity index 98% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.tsx index 32f1ac8afba..a6fe5f0cebb 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/render-into-canvas.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TNil } from '../..'; +import { TNil } from '../../index'; // exported for tests export const ITEM_ALPHA = 0.8; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx similarity index 97% rename from packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index a18fb38e213..1a7af88ca81 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -16,18 +16,17 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import { get as _get, maxBy as _maxBy, values as _values } from 'lodash'; import * as React from 'react'; -import MdKeyboardArrowRight from 'react-icons/lib/md/keyboard-arrow-right'; import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; -import { autoColor, TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '..'; import ExternalLinks from '../common/ExternalLinks'; import LabeledList from '../common/LabeledList'; import TraceName from '../common/TraceName'; +import { autoColor, TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../index'; import { getTraceLinks } from '../model/link-patterns'; import { getTraceName } from '../model/trace-viewer'; -import { Trace } from '../types/trace'; +import { Trace } from '../types'; import { uTxMuted } from '../uberUtilityStyles'; import { formatDuration } from '../utils/date'; @@ -247,7 +246,8 @@ export default function TracePageHeader(props: TracePageHeaderEmbedProps) { role="switch" aria-checked={!slimView} > - { reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, direction: 'next', }); @@ -140,6 +141,7 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps) const prevResult = () => { reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, direction: 'prev', }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/index.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/index.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/index.tsx rename to public/app/features/explore/TraceView/components/TracePageHeader/index.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.ts b/public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/Positions.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.ts rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/Positions.test.ts diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/Positions.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/Positions.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx index 4735fbe1e1a..64e0b2f4842 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx @@ -195,6 +195,7 @@ export default class ListView extends React.Component { this._htmlTopOffset = -1; this._windowScrollListenerAdded = false; // _htmlElm is only relevant if props.windowScroller is true + // eslint-disable-next-line this._htmlElm = document.documentElement as any; this._wrapperElm = undefined; this._itemHolderElm = undefined; @@ -378,6 +379,7 @@ export default class ListView extends React.Component { const nodes = this._itemHolderElm.childNodes; const max = nodes.length; for (let i = 0; i < max; i++) { + // eslint-disable-next-line const node: HTMLElement = nodes[i] as any; // use `.getAttribute(...)` instead of `.dataset` for jest / JSDOM const itemKey = node.getAttribute('data-item-key'); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx similarity index 98% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx index aca8cee5b5f..ebf46d12f1b 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx @@ -23,8 +23,7 @@ import { useStyles2 } from '@grafana/ui'; import { autoColor } from '../Theme'; import { Popover } from '../common/Popover'; -import { TNil } from '../types'; -import { TraceSpan } from '../types/trace'; +import { TraceSpan, TNil } from '../types'; import AccordianLogs from './SpanDetail/AccordianLogs'; import { ViewedBoundsFunctionType } from './utils'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx similarity index 98% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx index 23d684fdcd7..a1ef4b8296d 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.test.tsx @@ -15,10 +15,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { SpanLinks } from 'src/types/links'; -import { TraceSpan } from 'src/types/trace'; import { NONE, DURATION, TAG } from '../settings/SpanBarSettings'; +import { TraceSpan } from '../types'; +import { SpanLinks } from '../types/links'; import SpanBarRow, { SpanBarRowProps } from './SpanBarRow'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx similarity index 97% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx index ca2e19992bd..29ad671870e 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx @@ -15,17 +15,14 @@ import { css, keyframes } from '@emotion/css'; import cx from 'classnames'; import * as React from 'react'; -import IoAlert from 'react-icons/lib/io/alert'; -import IoArrowRightA from 'react-icons/lib/io/arrow-right-a'; import { GrafanaTheme2, TraceKeyValuePair } from '@grafana/data'; -import { stylesFactory, withTheme2 } from '@grafana/ui'; +import { Icon, stylesFactory, withTheme2 } from '@grafana/ui'; import { autoColor } from '../Theme'; import { DURATION, NONE, TAG } from '../settings/SpanBarSettings'; -import { SpanBarOptions, SpanLinkFunc, TNil } from '../types'; +import { SpanBarOptions, SpanLinkFunc, TraceSpan, TNil } from '../types'; import { SpanLinks } from '../types/links'; -import { TraceSpan } from '../types/trace'; import SpanBar from './SpanBar'; import { SpanLinksMenu } from './SpanLinks'; @@ -43,13 +40,13 @@ const nameColumnClassName = 'nameColumn'; const getStyles = stylesFactory((theme: GrafanaTheme2) => { const animations = { + label: 'flash', flash: keyframes` - label: flash; from { background-color: ${autoColor(theme, '#68b9ff')}; } to { - background-color: default; + background-color: 'default'; } `, }; @@ -458,7 +455,8 @@ export class UnthemedSpanBarRow extends React.PureComponent { })} > {showErrorIcon && ( - { {serviceName}{' '} {rpc && ( - + {' '} + {rpc.serviceName} )} {noInstrumentedServer && ( - {' '} + {' '} {noInstrumentedServer.serviceName} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx similarity index 92% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index 66db2f97d76..dfc31d55f8d 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -15,15 +15,12 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import * as React from 'react'; -import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; -import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; -import { TNil } from '../../types'; -import { TraceKeyValuePair, TraceLink } from '../../types/trace'; +import { TraceKeyValuePair, TraceLink, TNil } from '../../types'; import { uAlignIcon, uTxEllipsis } from '../../uberUtilityStyles'; import * as markers from './AccordianKeyValues.markers'; @@ -132,7 +129,11 @@ export default function AccordianKeyValues(props: AccordianKeyValuesProps) { let arrow: React.ReactNode | null = null; let headerProps: {} | null = null; if (interactive) { - arrow = isOpen ? : ; + arrow = isOpen ? ( + + ) : ( + + ); headerProps = { 'aria-checked': isOpen, onClick: isEmpty ? null : onToggle, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx similarity index 93% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index 72409092a1f..2eb8abefad3 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -15,11 +15,9 @@ import { css } from '@emotion/css'; import { sortBy as _sortBy } from 'lodash'; import * as React from 'react'; -import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; -import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TNil } from '../../types'; @@ -77,7 +75,11 @@ export default function AccordianLogs(props: AccordianLogsProps) { let HeaderComponent: 'span' | 'a' = 'span'; let headerProps: {} | null = null; if (interactive) { - arrow = isOpen ? : ; + arrow = isOpen ? ( + + ) : ( + + ); HeaderComponent = 'a'; headerProps = { 'aria-checked': isOpen, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx similarity index 96% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 3d4defe5cf6..24bf571ba9a 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -14,8 +14,6 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; -import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; -import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import { Field, GrafanaTheme2, LinkModel } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; @@ -199,7 +197,11 @@ const AccordianReferences: React.FC = ({ let HeaderComponent: 'span' | 'a' = 'span'; let headerProps: {} | null = null; if (interactive) { - arrow = isOpen ? : ; + arrow = isOpen ? ( + + ) : ( + + ); HeaderComponent = 'a'; headerProps = { 'aria-checked': isOpen, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx similarity index 91% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx index c7bbb516562..bcc2d6fa886 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx @@ -15,11 +15,9 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import * as React from 'react'; -import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; -import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TNil } from '../../types'; @@ -76,7 +74,11 @@ export default function AccordianText(props: AccordianTextProps) { let arrow: React.ReactNode | null = null; let headerProps: {} | null = null; if (interactive) { - arrow = isOpen ? : ; + arrow = isOpen ? ( + + ) : ( + + ); headerProps = { 'aria-checked': isOpen, onClick: isEmpty ? null : onToggle, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/DetailState.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/DetailState.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx similarity index 97% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index ec5ad19b68e..df3675a3d2c 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -22,8 +22,7 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import CopyIcon from '../../common/CopyIcon'; -import { TNil } from '../../types'; -import { TraceKeyValuePair, TraceLink } from '../../types/trace'; +import { TraceKeyValuePair, TraceLink, TNil } from '../../types'; import { ubInlineBlock, uWidth100 } from '../../uberUtilityStyles'; const copyIconClassName = 'copyIcon'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx index 081ec6bab28..260ec358c33 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx @@ -17,10 +17,10 @@ jest.mock('../utils'); import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { TraceSpanReference } from 'src/types/trace'; import traceGenerator from '../../demo/trace-generators'; import transformTraceData from '../../model/transform-trace-data'; +import { TraceSpanReference } from '../../types/trace'; import { formatDuration } from '../utils'; import DetailState from './DetailState'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx similarity index 87% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index b08bca9d7dd..f11a77dc230 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -15,11 +15,10 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import React from 'react'; -import IoLink from 'react-icons/lib/io/link'; import { dateTimeFormat, GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; -import { DataLinkButton, TextArea, useStyles2 } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { Button, DataLinkButton, Icon, TextArea, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { Divider } from '../../common/Divider'; @@ -194,22 +193,49 @@ export default function SpanDetail(props: SpanDetailProps) { : []), ]; const styles = useStyles2(getStyles); - const links = createSpanLink?.(span); - const focusSpanLink = createFocusSpanLink(traceID, spanID); - const logLink = links?.logLinks?.[0] - ? { - ...links?.logLinks?.[0], - onClick: (event: React.MouseEvent) => { - reportInteraction('grafana_traces_trace_view_span_link_clicked', { - datasourceType: datasourceType, - type: 'log', - location: 'spanDetails', - }); - links?.logLinks?.[0].onClick!(event); - }, - } - : undefined; + let logLinkButton: JSX.Element | undefined = undefined; + if (createSpanLink) { + const links = createSpanLink(span); + if (links?.logLinks) { + logLinkButton = ( + { + reportInteraction('grafana_traces_trace_view_span_link_clicked', { + datasourceType: datasourceType, + grafana_version: config.buildInfo.version, + type: 'log', + location: 'spanDetails', + }); + links?.logLinks?.[0].onClick?.(event); + }, + }} + buttonProps={{ icon: 'gf-logs' }} + /> + ); + } else { + logLinkButton = ( + + ); + } + } + + const focusSpanLink = createFocusSpanLink(traceID, spanID); return (
@@ -218,11 +244,7 @@ export default function SpanDetail(props: SpanDetailProps) {
- {links?.logLinks?.[0] ? ( - <> - - - ) : null} + {logLinkButton}
@@ -321,7 +343,7 @@ export default function SpanDetail(props: SpanDetailProps) { } }} > - + {spanID} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx similarity index 94% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanLinks.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx index 0380157ce7e..666470f1242 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanLinks.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { useStyles2, MenuGroup, MenuItem, Icon, ContextMenu } from '@grafana/ui'; import { SpanLinks } from '../types/links'; @@ -30,6 +30,7 @@ const renderMenuItems = ( ? (event) => { reportInteraction('grafana_traces_trace_view_span_link_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, type: 'log', location: 'menu', }); @@ -56,6 +57,7 @@ const renderMenuItems = ( ? (event) => { reportInteraction('grafana_traces_trace_view_span_link_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, type: 'metric', location: 'menu', }); @@ -82,6 +84,7 @@ const renderMenuItems = ( ? (event) => { reportInteraction('grafana_traces_trace_view_span_link_clicked', { datasourceType: datasourceType, + grafana_version: config.buildInfo.version, type: 'trace', location: 'menu', }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx index f9b91ef0899..a20f35688c6 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx @@ -15,10 +15,10 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { TraceSpan } from 'src/types/trace'; import { createTheme } from '@grafana/data'; +import { TraceSpan } from '../types'; import spanAncestorIdsSpy from '../utils/span-ancestor-ids'; import SpanTreeOffset, { getStyles, TProps } from './SpanTreeOffset'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx similarity index 94% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx index 149252a2f77..452389a5572 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx @@ -16,14 +16,12 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import { get as _get } from 'lodash'; import React from 'react'; -import IoChevronRight from 'react-icons/lib/io/chevron-right'; -import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; import { GrafanaTheme2 } from '@grafana/data'; -import { stylesFactory, withTheme2 } from '@grafana/ui'; +import { Icon, stylesFactory, withTheme2 } from '@grafana/ui'; import { autoColor } from '../Theme'; -import { TraceSpan } from '../types/trace'; +import { TraceSpan } from '../types'; import spanAncestorIds from '../utils/span-ancestor-ids'; export const getStyles = stylesFactory((theme: GrafanaTheme2) => { @@ -142,9 +140,9 @@ export class UnthemedSpanTreeOffset extends React.PureComponent { showChildrenIcon && hasChildren && (childrenVisible ? ( - + ) : ( - + )); const styles = getStyles(theme); return ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx index d8be774af0f..6e58221feb4 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx @@ -178,7 +178,6 @@ export default class TimelineColumnResizer extends React.PureComponent< if (this._dragManager.isDragging() && this._rootElm && dragPosition != null) { isDraggingLeft = dragPosition < position; isDraggingRight = dragPosition > position; - left = `${dragPosition * 100}%`; // Draw a highlight from the current dragged position back to the original // position, e.g. highlight the change. Draw the highlight via `left` and // `right` css styles (simpler than using `width`). diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx index adfb8624894..273c1123ecf 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx @@ -85,7 +85,7 @@ export type TimelineViewingLayerProps = { * bounds for dragging need to be recalculated. In practice, the name column * width serves fine for this. */ - boundsInvalidator: any | null | undefined; + boundsInvalidator: number | null | undefined; updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; updateViewRangeTime: TUpdateViewRangeTimeFunction; viewRangeTime: ViewRangeTime; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/index.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/index.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineRow.tsx diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.test.tsx similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.test.tsx index f3dab2e1334..e387616ce86 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.test.tsx @@ -13,10 +13,10 @@ // limitations under the License. import { render, screen } from '@testing-library/react'; import React from 'react'; -import { Trace } from 'src/types/trace'; import traceGenerator from '../demo/trace-generators'; import transformTraceData from '../model/transform-trace-data'; +import { Trace } from '../types'; import SpanTreeOffset from './SpanTreeOffset'; import VirtualizedTraceView, { VirtualizedTraceViewProps } from './VirtualizedTraceView'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx similarity index 97% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx rename to public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx index b789298dae8..994bc9270e0 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -16,10 +16,10 @@ import { css } from '@emotion/css'; import { isEqual } from 'lodash'; import memoizeOne from 'memoize-one'; import * as React from 'react'; -import { createRef, RefObject } from 'react'; +import { RefObject } from 'react'; import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui'; import { Accessors } from '../ScrollManager'; @@ -196,7 +196,6 @@ const memoizedGetClipping = memoizeOne(getClipping, isEqual); // export from tests export class UnthemedVirtualizedTraceView extends React.Component { listView: ListView | TNil; - topTraceViewRef = createRef(); constructor(props: VirtualizedTraceViewProps) { super(props); @@ -210,12 +209,12 @@ export class UnthemedVirtualizedTraceView extends React.Component; - for (let i = 0; i < nextPropKeys.length; i += 1) { - if (nextProps[nextPropKeys[i]] !== this.props[nextPropKeys[i]]) { + let key: keyof VirtualizedTraceViewProps; + for (key in nextProps) { + if (nextProps[key] !== this.props[key]) { // Unless the only change was props.shouldScrollToFirstUiFindMatch changing to false. - if (nextPropKeys[i] === 'shouldScrollToFirstUiFindMatch') { - if (nextProps[nextPropKeys[i]]) { + if (key === 'shouldScrollToFirstUiFindMatch') { + if (nextProps[key]) { return true; } } else { @@ -541,6 +540,7 @@ export class UnthemedVirtualizedTraceView extends React.Component { start: number; end: number }; /** diff --git a/packages/jaeger-ui-components/src/Tween.test.ts b/public/app/features/explore/TraceView/components/Tween.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/Tween.test.ts rename to public/app/features/explore/TraceView/components/Tween.test.ts diff --git a/packages/jaeger-ui-components/src/Tween.tsx b/public/app/features/explore/TraceView/components/Tween.tsx similarity index 100% rename from packages/jaeger-ui-components/src/Tween.tsx rename to public/app/features/explore/TraceView/components/Tween.tsx diff --git a/packages/jaeger-ui-components/src/common/BreakableText.tsx b/public/app/features/explore/TraceView/components/common/BreakableText.tsx similarity index 97% rename from packages/jaeger-ui-components/src/common/BreakableText.tsx rename to public/app/features/explore/TraceView/components/common/BreakableText.tsx index ad8c4cecb6d..26ad069eb40 100644 --- a/packages/jaeger-ui-components/src/common/BreakableText.tsx +++ b/public/app/features/explore/TraceView/components/common/BreakableText.tsx @@ -43,7 +43,7 @@ export default function BreakableText( const { className, text, wordRegexp = WORD_RX } = props; const styles = useStyles2(getStyles); if (!text) { - return typeof text === 'string' ? text : null; + return null; } const spans = []; wordRegexp.exec(''); diff --git a/packages/jaeger-ui-components/src/common/CopyIcon.test.tsx b/public/app/features/explore/TraceView/components/common/CopyIcon.test.tsx similarity index 90% rename from packages/jaeger-ui-components/src/common/CopyIcon.test.tsx rename to public/app/features/explore/TraceView/components/common/CopyIcon.test.tsx index 9f0101501f1..9e00636459d 100644 --- a/packages/jaeger-ui-components/src/common/CopyIcon.test.tsx +++ b/public/app/features/explore/TraceView/components/common/CopyIcon.test.tsx @@ -13,12 +13,15 @@ // limitations under the License. import { render, screen } from '@testing-library/react'; -import * as copy from 'copy-to-clipboard'; import React from 'react'; import CopyIcon from './CopyIcon'; -jest.mock('copy-to-clipboard'); +Object.assign(navigator, { + clipboard: { + writeText: () => {}, + }, +}); describe('', () => { const props = { @@ -29,7 +32,7 @@ describe('', () => { let copySpy: jest.SpyInstance; beforeAll(() => { - copySpy = jest.spyOn(copy, 'default'); + copySpy = jest.spyOn(navigator.clipboard, 'writeText'); }); beforeEach(() => { diff --git a/packages/jaeger-ui-components/src/common/CopyIcon.tsx b/public/app/features/explore/TraceView/components/common/CopyIcon.tsx similarity index 96% rename from packages/jaeger-ui-components/src/common/CopyIcon.tsx rename to public/app/features/explore/TraceView/components/common/CopyIcon.tsx index 5fb166a7ada..f2d01c21c00 100644 --- a/packages/jaeger-ui-components/src/common/CopyIcon.tsx +++ b/public/app/features/explore/TraceView/components/common/CopyIcon.tsx @@ -14,7 +14,6 @@ import { css } from '@emotion/css'; import cx from 'classnames'; -import copy from 'copy-to-clipboard'; import React, { useState } from 'react'; import { Button, IconName, Tooltip, useStyles2 } from '@grafana/ui'; @@ -48,7 +47,7 @@ export default function CopyIcon(props: PropsType) { const [hasCopied, setHasCopied] = useState(false); const handleClick = () => { - copy(props.copyText); + navigator.clipboard.writeText(props.copyText); setHasCopied(true); }; diff --git a/packages/jaeger-ui-components/src/common/Divider.tsx b/public/app/features/explore/TraceView/components/common/Divider.tsx similarity index 100% rename from packages/jaeger-ui-components/src/common/Divider.tsx rename to public/app/features/explore/TraceView/components/common/Divider.tsx diff --git a/packages/jaeger-ui-components/src/common/ExternalLinks.tsx b/public/app/features/explore/TraceView/components/common/ExternalLinks.tsx similarity index 100% rename from packages/jaeger-ui-components/src/common/ExternalLinks.tsx rename to public/app/features/explore/TraceView/components/common/ExternalLinks.tsx diff --git a/packages/jaeger-ui-components/src/common/LabeledList.tsx b/public/app/features/explore/TraceView/components/common/LabeledList.tsx similarity index 97% rename from packages/jaeger-ui-components/src/common/LabeledList.tsx rename to public/app/features/explore/TraceView/components/common/LabeledList.tsx index 7ce89630e02..58df134c695 100644 --- a/packages/jaeger-ui-components/src/common/LabeledList.tsx +++ b/public/app/features/explore/TraceView/components/common/LabeledList.tsx @@ -28,7 +28,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { list-style: none; margin: 0; padding: 0; - ${divider === true && + ${divider && ` margin-right: -8px; display: flex; @@ -39,7 +39,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { LabeledListItem: css` label: LabeledListItem; display: inline-block; - ${divider === true && + ${divider && ` border-right: 1px solid ${autoColor(theme, '#ddd')}; padding: 0 8px; diff --git a/packages/jaeger-ui-components/src/common/NewWindowIcon.tsx b/public/app/features/explore/TraceView/components/common/NewWindowIcon.tsx similarity index 88% rename from packages/jaeger-ui-components/src/common/NewWindowIcon.tsx rename to public/app/features/explore/TraceView/components/common/NewWindowIcon.tsx index 1f5185d5df5..804d10fa03f 100644 --- a/packages/jaeger-ui-components/src/common/NewWindowIcon.tsx +++ b/public/app/features/explore/TraceView/components/common/NewWindowIcon.tsx @@ -15,9 +15,8 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import React from 'react'; -import IoAndroidOpen from 'react-icons/lib/io/android-open'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; export const getStyles = () => { return { @@ -37,7 +36,7 @@ export default function NewWindowIcon(props: Props) { const { isLarge, className, ...rest } = props; const styles = useStyles2(getStyles); const cls = cx({ [styles.NewWindowIconLarge]: isLarge }, className); - return ; + return ; } NewWindowIcon.defaultProps = { diff --git a/packages/jaeger-ui-components/src/common/Popover.tsx b/public/app/features/explore/TraceView/components/common/Popover.tsx similarity index 100% rename from packages/jaeger-ui-components/src/common/Popover.tsx rename to public/app/features/explore/TraceView/components/common/Popover.tsx diff --git a/packages/jaeger-ui-components/src/common/TraceName.tsx b/public/app/features/explore/TraceView/components/common/TraceName.tsx similarity index 100% rename from packages/jaeger-ui-components/src/common/TraceName.tsx rename to public/app/features/explore/TraceView/components/common/TraceName.tsx diff --git a/packages/jaeger-ui-components/src/common/UiFindInput.test.tsx b/public/app/features/explore/TraceView/components/common/UiFindInput.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/common/UiFindInput.test.tsx rename to public/app/features/explore/TraceView/components/common/UiFindInput.test.tsx diff --git a/packages/jaeger-ui-components/src/common/UiFindInput.tsx b/public/app/features/explore/TraceView/components/common/UiFindInput.tsx similarity index 94% rename from packages/jaeger-ui-components/src/common/UiFindInput.tsx rename to public/app/features/explore/TraceView/components/common/UiFindInput.tsx index b6ca9fff3fe..c98db541ae4 100644 --- a/packages/jaeger-ui-components/src/common/UiFindInput.tsx +++ b/public/app/features/explore/TraceView/components/common/UiFindInput.tsx @@ -16,13 +16,12 @@ import * as React from 'react'; import { IconButton, Input } from '@grafana/ui'; -import { TNil } from '../types/index'; +import { TNil } from '../types'; type Props = { allowClear?: boolean; - inputProps: Record; + inputProps: Record; location: Location; - match: any; trackFindFunction?: (str: string | TNil) => void; value: string | undefined; onChange: (value: string) => void; diff --git a/packages/jaeger-ui-components/src/constants/index.tsx b/public/app/features/explore/TraceView/components/constants/default-config.ts similarity index 50% rename from packages/jaeger-ui-components/src/constants/index.tsx rename to public/app/features/explore/TraceView/components/constants/default-config.ts index 3d2483e6344..7fddc8d938a 100644 --- a/packages/jaeger-ui-components/src/constants/index.tsx +++ b/public/app/features/explore/TraceView/components/constants/default-config.ts @@ -12,15 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -export const FALLBACK_DAG_MAX_NUM_SERVICES = 100 as 100; -export const FALLBACK_TRACE_NAME = '' as ''; +import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index'; -export const FETCH_DONE = 'FETCH_DONE' as 'FETCH_DONE'; -export const FETCH_ERROR = 'FETCH_ERROR' as 'FETCH_ERROR'; -export const FETCH_LOADING = 'FETCH_LOADING' as 'FETCH_LOADING'; - -export const fetchedState = { - DONE: FETCH_DONE, - ERROR: FETCH_ERROR, - LOADING: FETCH_LOADING, -}; +export default Object.defineProperty( + { + archiveEnabled: false, + dependencies: { + dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES, + menuEnabled: true, + }, + linkPatterns: [], + search: { + maxLookback: { + label: '2 Days', + value: '2d', + }, + maxLimit: 1500, + }, + tracking: { + gaID: null, + trackErrors: true, + }, + }, + // fields that should be individually merged vs wholesale replaced + '__mergeFields', + { value: ['dependencies', 'search', 'tracking'] } +); diff --git a/packages/jaeger-ui-components/src/types/api-error.tsx b/public/app/features/explore/TraceView/components/constants/index.tsx similarity index 75% rename from packages/jaeger-ui-components/src/types/api-error.tsx rename to public/app/features/explore/TraceView/components/constants/index.tsx index fd72371e34f..15726a21be1 100644 --- a/packages/jaeger-ui-components/src/types/api-error.tsx +++ b/public/app/features/explore/TraceView/components/constants/index.tsx @@ -12,13 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -export type ApiError = - | string - | { - message: string; - httpStatus?: any; - httpStatusText?: string; - httpUrl?: string; - httpQuery?: string; - httpBody?: string; - }; +export const FALLBACK_DAG_MAX_NUM_SERVICES = 100 as 100; +export const FALLBACK_TRACE_NAME = ''; diff --git a/packages/jaeger-ui-components/src/constants/tag-keys.tsx b/public/app/features/explore/TraceView/components/constants/tag-keys.tsx similarity index 77% rename from packages/jaeger-ui-components/src/constants/tag-keys.tsx rename to public/app/features/explore/TraceView/components/constants/tag-keys.tsx index fb4587a547d..0aec89c75a5 100644 --- a/packages/jaeger-ui-components/src/constants/tag-keys.tsx +++ b/public/app/features/explore/TraceView/components/constants/tag-keys.tsx @@ -12,6 +12,4 @@ // See the License for the specific language governing permissions and // limitations under the License. -export const HTTP_METHOD = 'http.method' as 'http.method'; -export const PEER_SERVICE = 'peer.service' as 'peer.service'; -export const SPAN_KIND = 'span.kind' as 'span.kind'; +export const PEER_SERVICE = 'peer.service'; diff --git a/packages/jaeger-ui-components/src/demo/.eslintrc b/public/app/features/explore/TraceView/components/demo/.eslintrc similarity index 100% rename from packages/jaeger-ui-components/src/demo/.eslintrc rename to public/app/features/explore/TraceView/components/demo/.eslintrc diff --git a/packages/jaeger-ui-components/src/demo/chance.d.ts b/public/app/features/explore/TraceView/components/demo/chance.d.ts similarity index 100% rename from packages/jaeger-ui-components/src/demo/chance.d.ts rename to public/app/features/explore/TraceView/components/demo/chance.d.ts diff --git a/packages/jaeger-ui-components/src/demo/trace-generators.ts b/public/app/features/explore/TraceView/components/demo/trace-generators.ts similarity index 97% rename from packages/jaeger-ui-components/src/demo/trace-generators.ts rename to public/app/features/explore/TraceView/components/demo/trace-generators.ts index 4cdd08a4609..a87889233f4 100644 --- a/packages/jaeger-ui-components/src/demo/trace-generators.ts +++ b/public/app/features/explore/TraceView/components/demo/trace-generators.ts @@ -13,7 +13,8 @@ // limitations under the License. import Chance from 'chance'; -import { TraceSpanData, TraceProcess } from 'src/types/trace'; + +import { TraceSpanData, TraceProcess } from 'app/features/explore/TraceView/components/types/trace'; import { getSpanId } from '../selectors/span'; @@ -119,7 +120,6 @@ export default chance.mixin({ }); spans = attachReferences(spans, maxDepth, spansPerLevel); if (spans.length > 1) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions spans = setupParentSpan(spans, { startTime: timestamp, duration } as TraceSpanData); } diff --git a/packages/jaeger-ui-components/src/index.ts b/public/app/features/explore/TraceView/components/index.ts similarity index 100% rename from packages/jaeger-ui-components/src/index.ts rename to public/app/features/explore/TraceView/components/index.ts diff --git a/packages/jaeger-ui-components/src/keyboard-mappings.tsx b/public/app/features/explore/TraceView/components/keyboard-mappings.tsx similarity index 100% rename from packages/jaeger-ui-components/src/keyboard-mappings.tsx rename to public/app/features/explore/TraceView/components/keyboard-mappings.tsx diff --git a/packages/jaeger-ui-components/src/keyboard-shortcuts.tsx b/public/app/features/explore/TraceView/components/keyboard-shortcuts.tsx similarity index 92% rename from packages/jaeger-ui-components/src/keyboard-shortcuts.tsx rename to public/app/features/explore/TraceView/components/keyboard-shortcuts.tsx index 692935f7940..d038611b5d2 100644 --- a/packages/jaeger-ui-components/src/keyboard-shortcuts.tsx +++ b/public/app/features/explore/TraceView/components/keyboard-shortcuts.tsx @@ -19,8 +19,8 @@ import keyboardMappings from './keyboard-mappings'; export type CombokeysHandler = | (() => void) - | ((event: React.KeyboardEvent) => void) - | ((event: React.KeyboardEvent, s: string) => void); + | ((event: React.KeyboardEvent) => void) + | ((event: React.KeyboardEvent, s: string) => void); export type ShortcutCallbacks = { [name: string]: CombokeysHandler; diff --git a/packages/jaeger-ui-components/src/model/ddg/PathElem.test.ts b/public/app/features/explore/TraceView/components/model/ddg/PathElem.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/model/ddg/PathElem.test.ts rename to public/app/features/explore/TraceView/components/model/ddg/PathElem.test.ts diff --git a/packages/jaeger-ui-components/src/model/ddg/PathElem.tsx b/public/app/features/explore/TraceView/components/model/ddg/PathElem.tsx similarity index 100% rename from packages/jaeger-ui-components/src/model/ddg/PathElem.tsx rename to public/app/features/explore/TraceView/components/model/ddg/PathElem.tsx diff --git a/packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.ts.snap b/public/app/features/explore/TraceView/components/model/ddg/__snapshots__/PathElem.test.ts.snap similarity index 100% rename from packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.ts.snap rename to public/app/features/explore/TraceView/components/model/ddg/__snapshots__/PathElem.test.ts.snap diff --git a/packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.ts b/public/app/features/explore/TraceView/components/model/ddg/sample-paths.test.resources.ts similarity index 100% rename from packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.ts rename to public/app/features/explore/TraceView/components/model/ddg/sample-paths.test.resources.ts diff --git a/packages/jaeger-ui-components/src/model/ddg/types.tsx b/public/app/features/explore/TraceView/components/model/ddg/types.tsx similarity index 76% rename from packages/jaeger-ui-components/src/model/ddg/types.tsx rename to public/app/features/explore/TraceView/components/model/ddg/types.tsx index f6d4f0f965a..6705c4ea0c3 100644 --- a/packages/jaeger-ui-components/src/model/ddg/types.tsx +++ b/public/app/features/explore/TraceView/components/model/ddg/types.tsx @@ -27,20 +27,8 @@ export type TDdgOperation = { service: TDdgService; }; -export type TDdgServiceMap = Map; - export type TDdgPath = { focalIdx: number; members: PathElem[]; traceIDs: string[]; }; - -export type TDdgDistanceToPathElems = Map; - -export type TDdgModel = { - distanceToPathElems: TDdgDistanceToPathElems; - hash: string; - paths: TDdgPath[]; - services: TDdgServiceMap; - visIdxToPathElem: PathElem[]; -}; diff --git a/packages/jaeger-ui-components/src/model/find-trace-name.test.ts b/public/app/features/explore/TraceView/components/model/find-trace-name.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/model/find-trace-name.test.ts rename to public/app/features/explore/TraceView/components/model/find-trace-name.test.ts index bec8fefd8c3..d27cc98bdc4 100644 --- a/packages/jaeger-ui-components/src/model/find-trace-name.test.ts +++ b/public/app/features/explore/TraceView/components/model/find-trace-name.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceSpan } from '../types/trace'; +import { TraceSpan } from '../types'; import { _getTraceNameImpl as getTraceName } from './trace-viewer'; diff --git a/packages/jaeger-ui-components/src/model/link-patterns.test.ts b/public/app/features/explore/TraceView/components/model/link-patterns.test.ts similarity index 98% rename from packages/jaeger-ui-components/src/model/link-patterns.test.ts rename to public/app/features/explore/TraceView/components/model/link-patterns.test.ts index 0a6768febd9..d31063ffc7c 100644 --- a/packages/jaeger-ui-components/src/model/link-patterns.test.ts +++ b/public/app/features/explore/TraceView/components/model/link-patterns.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Trace, TraceLink, TraceSpan } from '../types/trace'; +import { Trace, TraceLink, TraceSpan } from '../types'; import { processTemplate, @@ -67,7 +67,6 @@ describe('processTemplate()', () => { expect(() => processTemplate( { - /* eslint-disable @typescript-eslint/no-explicit-any */ template: (data: { [key: string]: any }) => `a${data.b}c`, }, (a) => a @@ -417,7 +416,6 @@ describe('getLinks()', () => { const span = { depth: 0, process: {}, tags: [{ key: 'mySpecialKey', value: 'valueOfMyKey' }] } as TraceSpan; - /* eslint-disable @typescript-eslint/no-explicit-any */ let cache: WeakMap; beforeEach(() => { diff --git a/packages/jaeger-ui-components/src/model/link-patterns.tsx b/public/app/features/explore/TraceView/components/model/link-patterns.tsx similarity index 98% rename from packages/jaeger-ui-components/src/model/link-patterns.tsx rename to public/app/features/explore/TraceView/components/model/link-patterns.tsx index 9f74ccb532f..696c13c690c 100644 --- a/packages/jaeger-ui-components/src/model/link-patterns.tsx +++ b/public/app/features/explore/TraceView/components/model/link-patterns.tsx @@ -15,8 +15,7 @@ import { uniq as _uniq } from 'lodash'; import memoize from 'lru-memoize'; -import { TNil } from '../types'; -import { TraceSpan, TraceLink, TraceKeyValuePair, Trace } from '../types/trace'; +import { TraceSpan, TraceLink, TraceKeyValuePair, Trace, TNil } from '../types'; import { getConfigValue } from '../utils/config/get-config'; import { getParent } from './span'; diff --git a/packages/jaeger-ui-components/src/model/span.tsx b/public/app/features/explore/TraceView/components/model/span.tsx similarity index 95% rename from packages/jaeger-ui-components/src/model/span.tsx rename to public/app/features/explore/TraceView/components/model/span.tsx index 1450c357faf..07764898a24 100644 --- a/packages/jaeger-ui-components/src/model/span.tsx +++ b/public/app/features/explore/TraceView/components/model/span.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceSpan } from '../types/trace'; +import { TraceSpan } from '../types'; /** * Searches the span.references to find 'CHILD_OF' reference type or returns null. diff --git a/packages/jaeger-ui-components/src/model/trace-viewer.ts b/public/app/features/explore/TraceView/components/model/trace-viewer.ts similarity index 97% rename from packages/jaeger-ui-components/src/model/trace-viewer.ts rename to public/app/features/explore/TraceView/components/model/trace-viewer.ts index 105821920e6..857c4750587 100644 --- a/packages/jaeger-ui-components/src/model/trace-viewer.ts +++ b/public/app/features/explore/TraceView/components/model/trace-viewer.ts @@ -14,7 +14,7 @@ import { memoize } from 'lodash'; -import { TraceSpan } from '../types/trace'; +import { TraceSpan } from '../types'; export function _getTraceNameImpl(spans: TraceSpan[]) { // Use a span with no references to another span in given array diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.test.ts b/public/app/features/explore/TraceView/components/model/transform-trace-data.test.ts similarity index 98% rename from packages/jaeger-ui-components/src/model/transform-trace-data.test.ts rename to public/app/features/explore/TraceView/components/model/transform-trace-data.test.ts index 980b0bfabb9..8c3270ca88c 100644 --- a/packages/jaeger-ui-components/src/model/transform-trace-data.test.ts +++ b/public/app/features/explore/TraceView/components/model/transform-trace-data.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceResponse } from '../types/trace'; +import { TraceResponse } from '../types'; import transformTraceData, { orderTags, deduplicateTags } from './transform-trace-data'; diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx b/public/app/features/explore/TraceView/components/model/transform-trace-data.tsx similarity index 97% rename from packages/jaeger-ui-components/src/model/transform-trace-data.tsx rename to public/app/features/explore/TraceView/components/model/transform-trace-data.tsx index 49c10fac0aa..d116874f1cb 100644 --- a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx +++ b/public/app/features/explore/TraceView/components/model/transform-trace-data.tsx @@ -16,7 +16,7 @@ import { isEqual as _isEqual } from 'lodash'; // @ts-ignore import { getTraceSpanIdsAsTree } from '../selectors/trace'; -import { TraceKeyValuePair, TraceSpan, Trace, TraceResponse, TraceProcess } from '../types/trace'; +import { TraceKeyValuePair, TraceSpan, Trace, TraceResponse, TraceProcess } from '../types'; // @ts-ignore import TreeNode from '../utils/TreeNode'; import { getConfigValue } from '../utils/config/get-config'; @@ -142,7 +142,7 @@ export default function transformTraceData(data: TraceResponse | undefined): Tra if (typeof spanID !== 'string') { return; } - const span = spanMap.get(spanID) as TraceSpan; + const span = spanMap.get(spanID); if (!span) { return; } @@ -159,7 +159,7 @@ export default function transformTraceData(data: TraceResponse | undefined): Tra span.tags = orderTags(tagsInfo.tags, getConfigValue('topTagPrefixes')); span.warnings = span.warnings.concat(tagsInfo.warnings); span.references.forEach((ref, index) => { - const refSpan = spanMap.get(ref.spanID) as TraceSpan; + const refSpan = spanMap.get(ref.spanID); if (refSpan) { // eslint-disable-next-line no-param-reassign ref.span = refSpan; diff --git a/packages/jaeger-ui-components/src/scroll-page.test.ts b/public/app/features/explore/TraceView/components/scroll-page.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/scroll-page.test.ts rename to public/app/features/explore/TraceView/components/scroll-page.test.ts index 5c4a3e7f0c9..cff5b4084ba 100644 --- a/packages/jaeger-ui-components/src/scroll-page.test.ts +++ b/public/app/features/explore/TraceView/components/scroll-page.test.ts @@ -23,7 +23,6 @@ const tweenInstances: Tween[] = []; describe('scroll-by', () => { beforeEach(() => { - window.scrollY = 100; tweenInstances.length = 0; jest.mocked(Tween).mockClear(); jest.mocked(Tween).mockImplementation((opts) => { diff --git a/packages/jaeger-ui-components/src/scroll-page.tsx b/public/app/features/explore/TraceView/components/scroll-page.tsx similarity index 100% rename from packages/jaeger-ui-components/src/scroll-page.tsx rename to public/app/features/explore/TraceView/components/scroll-page.tsx diff --git a/packages/jaeger-ui-components/src/selectors/process.test.ts b/public/app/features/explore/TraceView/components/selectors/process.test.ts similarity index 96% rename from packages/jaeger-ui-components/src/selectors/process.test.ts rename to public/app/features/explore/TraceView/components/selectors/process.test.ts index d3928edfe43..cf6ad8caa7f 100644 --- a/packages/jaeger-ui-components/src/selectors/process.test.ts +++ b/public/app/features/explore/TraceView/components/selectors/process.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. import traceGenerator from '../demo/trace-generators'; -import { TraceProcess } from '../types/trace'; +import { TraceProcess } from '../types'; import * as processSelectors from './process'; diff --git a/packages/jaeger-ui-components/src/selectors/process.ts b/public/app/features/explore/TraceView/components/selectors/process.ts similarity index 94% rename from packages/jaeger-ui-components/src/selectors/process.ts rename to public/app/features/explore/TraceView/components/selectors/process.ts index d8d1a021630..91c2bcaa605 100644 --- a/packages/jaeger-ui-components/src/selectors/process.ts +++ b/public/app/features/explore/TraceView/components/selectors/process.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceProcess } from '../types/trace'; +import { TraceProcess } from '../types'; export const getProcessServiceName = (proc: TraceProcess) => proc.serviceName; export const getProcessTags = (proc: TraceProcess) => proc.tags; diff --git a/packages/jaeger-ui-components/src/selectors/span.test.ts b/public/app/features/explore/TraceView/components/selectors/span.test.ts similarity index 96% rename from packages/jaeger-ui-components/src/selectors/span.test.ts rename to public/app/features/explore/TraceView/components/selectors/span.test.ts index 0771b01283a..9186366a0f1 100644 --- a/packages/jaeger-ui-components/src/selectors/span.test.ts +++ b/public/app/features/explore/TraceView/components/selectors/span.test.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceResponse } from 'src/types'; -import { TraceSpan, TraceSpanData } from 'src/types/trace'; +import { TraceResponse } from 'app/features/explore/TraceView/components/types'; +import { TraceSpan, TraceSpanData } from 'app/features/explore/TraceView/components/types/trace'; import traceGenerator from '../demo/trace-generators'; diff --git a/packages/jaeger-ui-components/src/selectors/span.ts b/public/app/features/explore/TraceView/components/selectors/span.ts similarity index 76% rename from packages/jaeger-ui-components/src/selectors/span.ts rename to public/app/features/explore/TraceView/components/selectors/span.ts index 179dd1b8483..9f7d10eeb26 100644 --- a/packages/jaeger-ui-components/src/selectors/span.ts +++ b/public/app/features/explore/TraceView/components/selectors/span.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import fuzzy from 'fuzzy'; import { createSelector } from 'reselect'; +import { fuzzyMatch } from '@grafana/ui'; + import { TraceSpan, TraceSpanData, TraceSpanReference } from '../types/trace'; import { getProcessServiceName } from './process'; @@ -61,30 +62,5 @@ export const filterSpansForText = createSelector( ({ spans }: { spans: TraceSpan[] }) => spans, ({ text }: { text: string }) => text, (spans, text) => - fuzzy - .filter(text, spans, { - extract: (span) => `${getSpanServiceName(span)} ${getSpanName(span)}`, - }) - .map(({ original }) => original) -); - -const getTextFilteredSpansAsMap = createSelector(filterSpansForText, (matchingSpans) => - matchingSpans.reduce( - (obj, span) => ({ - ...obj, - [getSpanId(span)]: span, - }), - {} - ) -); - -// TODO: delete this function as it is not used? -export const highlightSpansForTextFilter = createSelector( - ({ spans }: { spans: TraceSpanData[] }) => spans, - getTextFilteredSpansAsMap, - (spans, textFilteredSpansMap: { [key: string]: TraceSpanData }) => - spans.map((span: TraceSpanData) => ({ - ...span, - muted: !textFilteredSpansMap[getSpanId(span)], - })) + spans.filter((span) => (span ? fuzzyMatch(`${getSpanServiceName(span)} ${getSpanName(span)}`, text).found : false)) ); diff --git a/packages/jaeger-ui-components/src/selectors/trace.fixture.ts b/public/app/features/explore/TraceView/components/selectors/trace.fixture.ts similarity index 100% rename from packages/jaeger-ui-components/src/selectors/trace.fixture.ts rename to public/app/features/explore/TraceView/components/selectors/trace.fixture.ts diff --git a/packages/jaeger-ui-components/src/selectors/trace.test.ts b/public/app/features/explore/TraceView/components/selectors/trace.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/selectors/trace.test.ts rename to public/app/features/explore/TraceView/components/selectors/trace.test.ts index dfaa9d28b93..1aec2d75aa8 100644 --- a/packages/jaeger-ui-components/src/selectors/trace.test.ts +++ b/public/app/features/explore/TraceView/components/selectors/trace.test.ts @@ -13,7 +13,8 @@ // limitations under the License. import { values as _values } from 'lodash'; -import TreeNode from 'src/utils/TreeNode'; + +import TreeNode from 'app/features/explore/TraceView/components/utils/TreeNode'; import traceGenerator from '../demo/trace-generators'; import { TraceResponse, TraceSpan, TraceSpanData } from '../types/trace'; diff --git a/packages/jaeger-ui-components/src/selectors/trace.ts b/public/app/features/explore/TraceView/components/selectors/trace.ts similarity index 81% rename from packages/jaeger-ui-components/src/selectors/trace.ts rename to public/app/features/explore/TraceView/components/selectors/trace.ts index f9c0ebb85ca..288910b07e6 100644 --- a/packages/jaeger-ui-components/src/selectors/trace.ts +++ b/public/app/features/explore/TraceView/components/selectors/trace.ts @@ -14,7 +14,7 @@ import { createSelector, createStructuredSelector } from 'reselect'; -import { Trace, TraceData, TraceProcess, TraceResponse, TraceSpan, TraceSpanData } from '../types/trace'; +import { Trace, TraceData, TraceProcess, TraceResponse, TraceSpanData } from '../types/trace'; import TreeNode from '../utils/TreeNode'; import { formatMillisecondTime, formatSecondTime, ONE_SECOND } from '../utils/date'; import { numberSortComparator } from '../utils/sort'; @@ -126,12 +126,6 @@ export const getTraceDuration = createSelector(getTraceSpans, getTraceTimestamp, ) ); -export const getTraceEndTimestamp = createSelector( - getTraceTimestamp, - getTraceDuration, - (timestamp: number, duration: number) => timestamp! + duration -); - export const getParentSpan = createSelector( getTraceSpanIdsAsTree, getTraceSpansAsMap, @@ -199,13 +193,6 @@ export const getSortedSpans = createSelector( [...spans].sort((spanA, spanB) => dir * comparator(selector(spanA, trace), selector(spanB, trace))) ); -const getTraceSpansByHierarchyPosition = createSelector(getTraceSpanIdsAsTree, (tree) => { - const hierarchyPositionMap = new Map(); - let i = 0; - tree.walk((spanID: string | number | undefined) => hierarchyPositionMap.set(spanID, i++)); - return hierarchyPositionMap; -}); - export const getTreeSizeForTraceSpan = createSelector( createSelector((state: { trace: TraceResponse }) => state.trace, getTraceSpanIdsAsTree), createSelector((state: { span: TraceSpanData }) => state.span, getSpanId), @@ -218,12 +205,6 @@ export const getTreeSizeForTraceSpan = createSelector( } ); -export const getSpanHierarchySortPositionForTrace = createSelector( - createSelector(({ trace }: { trace: Trace }) => trace, getTraceSpansByHierarchyPosition), - ({ span }: { span: TraceSpan }) => span, - (hierarchyPositionMap, span) => hierarchyPositionMap.get(getSpanId(span)) -); - export const getTraceName = createSelector( createSelector( createSelector(hydrateSpansWithProcesses, getParentSpan), @@ -266,43 +247,3 @@ export const getTicksForTrace = createSelector( width, })) ); - -// TODO: delete this when the backend can ensure uniqueness -/* istanbul ignore next */ -export const enforceUniqueSpanIds = createSelector( - /* istanbul ignore next */ (trace: Trace) => trace, - getTraceSpans, - /* istanbul ignore next */ (trace, spans) => { - const map = new Map(); - - const spanArray: TraceSpanData[] = []; - - return { - ...trace, - spans: spans.reduce((result: TraceSpanData[], span: TraceSpanData) => { - const spanID = map.has(getSpanId(span)) ? `${getSpanId(span)}_${map.get(getSpanId(span))}` : getSpanId(span); - const updatedSpan = { ...span, spanID }; - - if (spanID !== getSpanId(span)) { - // eslint-disable-next-line no-console - console.warn('duplicate spanID in trace replaced', getSpanId(span), 'new:', spanID); - } - - // set the presence of the span in the map or increment the number - map.set(getSpanId(span), (map.get(getSpanId(span)) || 0) + 1); - - return result.concat([updatedSpan]); - }, spanArray), - }; - } -); - -// TODO: delete this when the backend can ensure uniqueness -export const dropEmptyStartTimeSpans = createSelector( - /* istanbul ignore next */ (trace: Trace) => trace, - getTraceSpans, - /* istanbul ignore next */ (trace, spans) => ({ - ...trace, - spans: spans.filter((span: TraceSpanData) => !!getSpanTimestamp(span)), - }) -); diff --git a/packages/jaeger-ui-components/src/settings/SpanBarSettings.tsx b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx similarity index 100% rename from packages/jaeger-ui-components/src/settings/SpanBarSettings.tsx rename to public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx diff --git a/packages/jaeger-ui-components/src/types/TNil.tsx b/public/app/features/explore/TraceView/components/types/TNil.tsx similarity index 100% rename from packages/jaeger-ui-components/src/types/TNil.tsx rename to public/app/features/explore/TraceView/components/types/TNil.tsx diff --git a/packages/jaeger-ui-components/src/types/TTraceDiffState.tsx b/public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx similarity index 100% rename from packages/jaeger-ui-components/src/types/TTraceDiffState.tsx rename to public/app/features/explore/TraceView/components/types/TTraceDiffState.tsx diff --git a/packages/jaeger-ui-components/src/types/TTraceTimeline.tsx b/public/app/features/explore/TraceView/components/types/TTraceTimeline.tsx similarity index 100% rename from packages/jaeger-ui-components/src/types/TTraceTimeline.tsx rename to public/app/features/explore/TraceView/components/types/TTraceTimeline.tsx diff --git a/packages/jaeger-ui-components/src/types/config.tsx b/public/app/features/explore/TraceView/components/types/config.tsx similarity index 97% rename from packages/jaeger-ui-components/src/types/config.tsx rename to public/app/features/explore/TraceView/components/types/config.tsx index 6bd91da7c99..944a2a278de 100644 --- a/packages/jaeger-ui-components/src/types/config.tsx +++ b/public/app/features/explore/TraceView/components/types/config.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TNil } from '.'; +import { TNil } from './index'; export type ConfigMenuItem = { label: string; diff --git a/packages/jaeger-ui-components/src/types/index.tsx b/public/app/features/explore/TraceView/components/types/index.tsx similarity index 100% rename from packages/jaeger-ui-components/src/types/index.tsx rename to public/app/features/explore/TraceView/components/types/index.tsx diff --git a/packages/jaeger-ui-components/src/types/links.ts b/public/app/features/explore/TraceView/components/types/links.ts similarity index 78% rename from packages/jaeger-ui-components/src/types/links.ts rename to public/app/features/explore/TraceView/components/types/links.ts index bb4776bc9f6..8e375b2f0d3 100644 --- a/packages/jaeger-ui-components/src/types/links.ts +++ b/public/app/features/explore/TraceView/components/types/links.ts @@ -1,12 +1,15 @@ import React from 'react'; +import { Field } from '@grafana/data'; + import { TraceSpan } from './trace'; export type SpanLinkDef = { href: string; - onClick?: (event: any) => void; + onClick?: (event: unknown) => void; content: React.ReactNode; title?: string; + field: Field; }; export type SpanLinks = { diff --git a/packages/jaeger-ui-components/src/types/archive.tsx b/public/app/features/explore/TraceView/components/types/search.tsx similarity index 67% rename from packages/jaeger-ui-components/src/types/archive.tsx rename to public/app/features/explore/TraceView/components/types/search.tsx index 1006800d203..a2ce3710a93 100644 --- a/packages/jaeger-ui-components/src/types/archive.tsx +++ b/public/app/features/explore/TraceView/components/types/search.tsx @@ -12,14 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ApiError } from './api-error'; +import { TNil } from './index'; -export type TraceArchive = { - isLoading?: boolean; - isArchived?: boolean; - isError?: boolean; - error?: ApiError; - isAcknowledged?: boolean; +export type SearchQuery = { + end: number | string; + limit: number | string; + lookback: string; + maxDuration: null | string; + minDuration: null | string; + operation: string | TNil; + service: string; + start: number | string; + tags: string | TNil; }; - -export type TracesArchive = Record; diff --git a/packages/jaeger-ui-components/src/types/trace.ts b/public/app/features/explore/TraceView/components/types/trace.ts similarity index 100% rename from packages/jaeger-ui-components/src/types/trace.ts rename to public/app/features/explore/TraceView/components/types/trace.ts diff --git a/packages/jaeger-ui-components/src/uberUtilityStyles.ts b/public/app/features/explore/TraceView/components/uberUtilityStyles.ts similarity index 100% rename from packages/jaeger-ui-components/src/uberUtilityStyles.ts rename to public/app/features/explore/TraceView/components/uberUtilityStyles.ts diff --git a/packages/jaeger-ui-components/src/url/ReferenceLink.test.tsx b/public/app/features/explore/TraceView/components/url/ReferenceLink.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/url/ReferenceLink.test.tsx rename to public/app/features/explore/TraceView/components/url/ReferenceLink.test.tsx diff --git a/packages/jaeger-ui-components/src/url/ReferenceLink.tsx b/public/app/features/explore/TraceView/components/url/ReferenceLink.tsx similarity index 100% rename from packages/jaeger-ui-components/src/url/ReferenceLink.tsx rename to public/app/features/explore/TraceView/components/url/ReferenceLink.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.ts b/public/app/features/explore/TraceView/components/utils/DraggableManager/DraggableManager.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.ts rename to public/app/features/explore/TraceView/components/utils/DraggableManager/DraggableManager.test.ts diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/DraggableManager.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/DraggableManager.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/EUpdateTypes.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/EUpdateTypes.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/README.md b/public/app/features/explore/TraceView/components/utils/DraggableManager/README.md similarity index 99% rename from packages/jaeger-ui-components/src/utils/DraggableManager/README.md rename to public/app/features/explore/TraceView/components/utils/DraggableManager/README.md index 6f20f3126e8..ff98846477f 100644 --- a/packages/jaeger-ui-components/src/utils/DraggableManager/README.md +++ b/public/app/features/explore/TraceView/components/utils/DraggableManager/README.md @@ -142,7 +142,7 @@ This generally amounts to calling [`Element#getBoundingClientRect()`](https://de For instance, in the `DividerDemo`, the function used is `DivideDemo#_getDraggingBounds()`: -```js +```ts _getDraggingBounds = (): DraggableBounds => { if (!this._realmElm) { throw new Error('invalid state'); diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DividerDemo.css similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DividerDemo.css diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DividerDemo.tsx similarity index 97% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DividerDemo.tsx index ca8ce693956..26ac8b73b56 100644 --- a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx +++ b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DividerDemo.tsx @@ -14,9 +14,9 @@ import React from 'react'; -import { DraggableBounds, DraggingUpdate } from '..'; import TNil from '../../../types/TNil'; import DraggableManager from '../DraggableManager'; +import { DraggableBounds, DraggingUpdate } from '../index'; import './DividerDemo.css'; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DraggableManagerDemo.css similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DraggableManagerDemo.css diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DraggableManagerDemo.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/DraggableManagerDemo.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/RegionDemo.css similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/RegionDemo.css diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/RegionDemo.tsx similarity index 99% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/RegionDemo.tsx index 5338d7f7c4d..f25f9f05320 100644 --- a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx +++ b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/RegionDemo.tsx @@ -14,8 +14,8 @@ import React from 'react'; -import DraggableManager, { DraggableBounds, DraggingUpdate } from '..'; import { TNil } from '../../../types'; +import DraggableManager, { DraggableBounds, DraggingUpdate } from '../index'; import './RegionDemo.css'; diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/demo-ux.gif similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/demo-ux.gif diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/demo/index.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/demo/index.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/index.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/index.tsx diff --git a/packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx b/public/app/features/explore/TraceView/components/utils/DraggableManager/types.tsx similarity index 95% rename from packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx rename to public/app/features/explore/TraceView/components/utils/DraggableManager/types.tsx index b10c4581199..79e213f8d7e 100644 --- a/packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx +++ b/public/app/features/explore/TraceView/components/utils/DraggableManager/types.tsx @@ -27,7 +27,7 @@ export type DraggableBounds = { }; export type DraggingUpdate = { - event: React.MouseEvent | MouseEvent; + event: React.MouseEvent | MouseEvent; manager: DraggableManager; tag: string | TNil; type: EUpdateTypes; diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.test.ts b/public/app/features/explore/TraceView/components/utils/TreeNode.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/TreeNode.test.ts rename to public/app/features/explore/TraceView/components/utils/TreeNode.test.ts diff --git a/packages/jaeger-ui-components/src/utils/TreeNode.ts b/public/app/features/explore/TraceView/components/utils/TreeNode.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/TreeNode.ts rename to public/app/features/explore/TraceView/components/utils/TreeNode.ts diff --git a/packages/jaeger-ui-components/src/utils/color-generator.test.ts b/public/app/features/explore/TraceView/components/utils/color-generator.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/color-generator.test.ts rename to public/app/features/explore/TraceView/components/utils/color-generator.test.ts diff --git a/packages/jaeger-ui-components/src/utils/color-generator.tsx b/public/app/features/explore/TraceView/components/utils/color-generator.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/color-generator.tsx rename to public/app/features/explore/TraceView/components/utils/color-generator.tsx diff --git a/packages/jaeger-ui-components/src/utils/config/get-config.tsx b/public/app/features/explore/TraceView/components/utils/config/get-config.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/config/get-config.tsx rename to public/app/features/explore/TraceView/components/utils/config/get-config.tsx diff --git a/packages/jaeger-ui-components/src/utils/date.test.ts b/public/app/features/explore/TraceView/components/utils/date.test.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/date.test.ts rename to public/app/features/explore/TraceView/components/utils/date.test.ts diff --git a/packages/jaeger-ui-components/src/utils/date.tsx b/public/app/features/explore/TraceView/components/utils/date.tsx similarity index 76% rename from packages/jaeger-ui-components/src/utils/date.tsx rename to public/app/features/explore/TraceView/components/utils/date.tsx index 1a0277f189f..3c358885359 100644 --- a/packages/jaeger-ui-components/src/utils/date.tsx +++ b/public/app/features/explore/TraceView/components/utils/date.tsx @@ -17,12 +17,8 @@ import moment from 'moment-timezone'; import { toFloatPrecision } from './number'; -const TODAY = 'Today'; -const YESTERDAY = 'Yesterday'; - export const STANDARD_DATE_FORMAT = 'YYYY-MM-DD'; export const STANDARD_TIME_FORMAT = 'HH:mm'; -export const STANDARD_DATETIME_FORMAT = 'MMMM D YYYY, HH:mm:ss.SSS'; export const ONE_MILLISECOND = 1000; export const ONE_SECOND = 1000 * ONE_MILLISECOND; export const ONE_MINUTE = 60 * ONE_SECOND; @@ -39,16 +35,6 @@ const UNIT_STEPS: Array<{ unit: string; microseconds: number; ofPrevious: number { unit: 'μs', microseconds: 1, ofPrevious: 1000 }, ]; -/** - * @param {number} timestamp - * @param {number} initialTimestamp - * @param {number} totalDuration - * @returns {number} 0-100 percentage - */ -export function getPercentageOfDuration(duration: number, totalDuration: number) { - return (duration / totalDuration) * 100; -} - const quantizeDuration = (duration: number, floatPrecision: number, conversionFactor: number) => toFloatPrecision(duration / conversionFactor, floatPrecision) * conversionFactor; @@ -68,14 +54,6 @@ export function formatTime(duration: number) { return moment(duration / ONE_MILLISECOND).format(STANDARD_TIME_FORMAT); } -/** - * @param {number} duration (in microseconds) - * @returns {string} formatted, unit-labelled string with time in milliseconds - */ -export function formatDatetime(duration: number) { - return moment(duration / ONE_MILLISECOND).format(STANDARD_DATETIME_FORMAT); -} - /** * @param {number} duration (in microseconds) * @returns {string} formatted, unit-labelled string with time in milliseconds @@ -123,23 +101,3 @@ export function formatDuration(duration: number): string { const secondaryUnitString = `${secondaryValue}${secondaryUnit.unit}`; return secondaryValue === 0 ? primaryUnitString : `${primaryUnitString} ${secondaryUnitString}`; } - -export function formatRelativeDate(value: any, fullMonthName = false) { - const m = moment.isMoment(value) ? value : moment(value); - const monthFormat = fullMonthName ? 'MMMM' : 'MMM'; - const dt = new Date(); - if (dt.getFullYear() !== m.year()) { - return m.format(`${monthFormat} D, YYYY`); - } - const mMonth = m.month(); - const mDate = m.date(); - const date = dt.getDate(); - if (mMonth === dt.getMonth() && mDate === date) { - return TODAY; - } - dt.setDate(date - 1); - if (mMonth === dt.getMonth() && mDate === dt.getDate()) { - return YESTERDAY; - } - return m.format(`${monthFormat} D`); -} diff --git a/packages/jaeger-ui-components/src/utils/filter-spans.test.ts b/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/utils/filter-spans.test.ts rename to public/app/features/explore/TraceView/components/utils/filter-spans.test.ts index 48f704e4cc4..9123cd2106a 100644 --- a/packages/jaeger-ui-components/src/utils/filter-spans.test.ts +++ b/public/app/features/explore/TraceView/components/utils/filter-spans.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceSpan } from 'src/types/trace'; +import { TraceSpan } from '../types'; import filterSpans from './filter-spans'; diff --git a/packages/jaeger-ui-components/src/utils/filter-spans.tsx b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx similarity index 96% rename from packages/jaeger-ui-components/src/utils/filter-spans.tsx rename to public/app/features/explore/TraceView/components/utils/filter-spans.tsx index 699d8996487..4f93a6b42c1 100644 --- a/packages/jaeger-ui-components/src/utils/filter-spans.tsx +++ b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TNil } from '../types'; -import { TraceKeyValuePair, TraceSpan } from '../types/trace'; +import { TNil, TraceKeyValuePair, TraceSpan } from '../types'; export default function filterSpans(textFilter: string, spans: TraceSpan[] | TNil) { if (!spans) { diff --git a/packages/jaeger-ui-components/src/utils/number.tsx b/public/app/features/explore/TraceView/components/utils/number.tsx similarity index 100% rename from packages/jaeger-ui-components/src/utils/number.tsx rename to public/app/features/explore/TraceView/components/utils/number.tsx diff --git a/packages/jaeger-ui-components/src/utils/sort.test.ts b/public/app/features/explore/TraceView/components/utils/sort.test.ts similarity index 73% rename from packages/jaeger-ui-components/src/utils/sort.test.ts rename to public/app/features/explore/TraceView/components/utils/sort.test.ts index 43bede4d91c..1f427425072 100644 --- a/packages/jaeger-ui-components/src/utils/sort.test.ts +++ b/public/app/features/explore/TraceView/components/utils/sort.test.ts @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import sinon from 'sinon'; - import * as sortUtils from './sort'; it('localeStringComparator() provides a case-insensitive sort', () => { @@ -77,31 +75,3 @@ it('getNewSortForClick() should toggle direction if same column', () => { dir: 1, }); }); - -it('createSortClickHandler() should return a function', () => { - const column = { name: 'alpha' }; - const currentSortKey = 'alpha'; - const currentSortDir = 1; - const updateSort = sinon.spy(); - - expect(typeof sortUtils.createSortClickHandler(column, currentSortKey, currentSortDir, updateSort)).toBe('function'); -}); - -it('createSortClickHandler() should call updateSort with the new sort vals', () => { - const column = { name: 'alpha' }; - const prevSort = { key: 'alpha', dir: 1 }; - const currentSortKey = prevSort.key; - const currentSortDir = prevSort.dir; - const updateSort = sinon.spy(); - - const clickHandler = sortUtils.createSortClickHandler(column, currentSortKey, currentSortDir, updateSort); - - clickHandler(); - - expect( - updateSort.calledWith( - sortUtils.getNewSortForClick(prevSort, column).key, - sortUtils.getNewSortForClick(prevSort, column).dir - ) - ).toBeTruthy(); -}); diff --git a/packages/jaeger-ui-components/src/utils/sort.ts b/public/app/features/explore/TraceView/components/utils/sort.ts similarity index 77% rename from packages/jaeger-ui-components/src/utils/sort.ts rename to public/app/features/explore/TraceView/components/utils/sort.ts index 5d3c85872cc..5636fdc977f 100644 --- a/packages/jaeger-ui-components/src/utils/sort.ts +++ b/public/app/features/explore/TraceView/components/utils/sort.ts @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import sinon from 'sinon'; - export function localeStringComparator(itemA: string, itemB: string) { return itemA.localeCompare(itemB); } @@ -37,15 +35,3 @@ export function getNewSortForClick( dir: prevSort.key === column.name ? -1 * prevSort.dir : defaultDir, }; } - -export function createSortClickHandler( - column: { name: string }, - currentSortKey: string, - currentSortDir: number, - updateSort: sinon.SinonSpy -) { - return function onClickSortingElement() { - const { key, dir } = getNewSortForClick({ key: currentSortKey, dir: currentSortDir }, column); - updateSort(key, dir); - }; -} diff --git a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.ts b/public/app/features/explore/TraceView/components/utils/span-ancestor-ids.test.ts similarity index 98% rename from packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.ts rename to public/app/features/explore/TraceView/components/utils/span-ancestor-ids.test.ts index 76d6fc3d7b5..d8532b74151 100644 --- a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.ts +++ b/public/app/features/explore/TraceView/components/utils/span-ancestor-ids.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceSpan } from 'src/types/trace'; +import { TraceSpan } from '../types'; import spanAncestorIdsSpy from './span-ancestor-ids'; diff --git a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx b/public/app/features/explore/TraceView/components/utils/span-ancestor-ids.tsx similarity index 93% rename from packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx rename to public/app/features/explore/TraceView/components/utils/span-ancestor-ids.tsx index cb4677353d3..5ddb037aa2a 100644 --- a/packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx +++ b/public/app/features/explore/TraceView/components/utils/span-ancestor-ids.tsx @@ -14,8 +14,7 @@ import { find as _find, get as _get } from 'lodash'; -import { TNil } from '../types'; -import { TraceSpan } from '../types/trace'; +import { TNil, TraceSpan } from '../types'; function getFirstAncestor(span: TraceSpan): TraceSpan | TNil { return _get( diff --git a/packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.ts b/public/app/features/explore/TraceView/components/utils/test/requestAnimationFrame.ts similarity index 100% rename from packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.ts rename to public/app/features/explore/TraceView/components/utils/test/requestAnimationFrame.ts diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 8a6ff0f10eb..f1ad205615a 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -1,19 +1,26 @@ import { DataSourceInstanceSettings, LinkModel, MutableDataFrame } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime'; -import { TraceSpan } from '@jaegertracing/jaeger-ui-components'; import { TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { TraceToLogsOptions } from '../../../core/components/TraceToLogs/TraceToLogsSettings'; +import { TraceToLogsOptionsV2 } from '../../../core/components/TraceToLogs/TraceToLogsSettings'; import { LinkSrv, setLinkSrv } from '../../panel/panellinks/link_srv'; import { TemplateSrv } from '../../templating/template_srv'; +import { Trace, TraceSpan } from './components'; import { createSpanLinkFactory } from './createSpanLink'; +const dummyTraceData = { duration: 10, traceID: 'trace1', traceName: 'test trace' } as unknown as Trace; +const dummyDataFrame = new MutableDataFrame({ fields: [{ name: 'traceId', values: ['trace1'] }] }); + describe('createSpanLinkFactory', () => { it('returns no links if there is no data source uid', () => { const splitOpenFn = jest.fn(); - const createLink = createSpanLinkFactory({ splitOpenFn: splitOpenFn }); + const createLink = createSpanLinkFactory({ + splitOpenFn: splitOpenFn, + trace: dummyTraceData, + dataFrame: dummyDataFrame, + }); const links = createLink!(createTraceSpan()); expect(links?.logLinks).toBeUndefined(); expect(links?.metricLinks).toBeUndefined(); @@ -40,14 +47,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}' )}` ); }); it('with tags that passed in and without tags that are not in the span', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'newTag'], + tags: [{ key: 'ip' }, { key: 'newTag' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -65,14 +72,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}]}' )}` ); }); it('from tags and process tags as well', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'host'], + tags: [{ key: 'ip' }, { key: 'host' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -90,7 +97,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}]}' )}` ); }); @@ -116,7 +123,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}]}' )}` ); }); @@ -131,10 +138,18 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"} |=\\"7946b05c2e2e4e5a\\" |=\\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}' - )}` + expect(decodeURIComponent(linkDef!.href)).toBe( + '/explore?left=' + + JSON.stringify({ + range: { from: '2020-10-14T01:00:00.000Z', to: '2020-10-14T01:00:01.000Z' }, + datasource: 'loki1_uid', + queries: [ + { + expr: '{cluster="cluster1", hostname="hostname1"} |="7946b05c2e2e4e5a" |="6605c7b08e715d6c"', + refId: '', + }, + ], + }) ); }); @@ -152,6 +167,7 @@ describe('createSpanLinkFactory', () => { }, ], }), + trace: dummyTraceData, }); expect(createLink).toBeDefined(); const links = createLink!(createTraceSpan()); @@ -163,8 +179,7 @@ describe('createSpanLinkFactory', () => { it('handles renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -186,15 +201,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' )}` ); }); it('handles incomplete renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: '' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -216,7 +230,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' )}` ); }); @@ -239,6 +253,16 @@ describe('createSpanLinkFactory', () => { ); expect(links?.logLinks).toBeUndefined(); }); + + it('interpolates span intrinsics', () => { + const createLink = setupSpanLinkFactory({ + tags: [{ key: 'name', value: 'spanName' }], + }); + expect(createLink).toBeDefined(); + const links = createLink!(createTraceSpan()); + expect(links?.logLinks).toBeDefined(); + expect(decodeURIComponent(links!.logLinks![0].href)).toContain('spanName=\\"operation\\"'); + }); }); describe('should return splunk link', () => { @@ -301,14 +325,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}]}' )}` ); }); it('should format one tag correctly', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip'], + tags: [{ key: 'ip' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -324,14 +348,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}]}' )}` ); }); it('should format multiple tags correctly', () => { const createLink = setupSpanLinkFactory({ - tags: ['ip', 'hostname'], + tags: [{ key: 'ip' }, { key: 'hostname' }], }); expect(createLink).toBeDefined(); const links = createLink!( @@ -350,15 +374,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}]}' )}` ); }); it('handles renamed tags', () => { const createLink = setupSpanLinkFactory({ - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -380,7 +403,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}]}' )}` ); }); @@ -406,6 +429,8 @@ describe('createSpanLinkFactory', () => { datasourceUid: 'prom1Uid', queries: [{ query: 'customQuery' }], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -415,7 +440,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); }); @@ -427,6 +452,8 @@ describe('createSpanLinkFactory', () => { traceToMetricsOptions: { datasourceUid: 'prom1', } as TraceToMetricsOptions, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -446,6 +473,8 @@ describe('createSpanLinkFactory', () => { { query: 'no_name_here' }, ], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -458,7 +487,7 @@ describe('createSpanLinkFactory', () => { expect(namedLink!.title).toBe('Named Query'); expect(namedLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); @@ -467,7 +496,7 @@ describe('createSpanLinkFactory', () => { expect(defaultLink!.title).toBe('defaultQuery'); expect(defaultLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(tempo_spanmetrics_latency_bucket{operation=\\"operation\\"}[5m])) by (le))","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service=\\"test service\\"}[5m])) by (le))","refId":"A"}]}' )}` ); @@ -476,7 +505,7 @@ describe('createSpanLinkFactory', () => { expect(unnamedQuery!.title).toBeUndefined(); expect(unnamedQuery!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A"}]}' )}` ); }); @@ -491,6 +520,8 @@ describe('createSpanLinkFactory', () => { spanStartTimeShift: '-1h', spanEndTimeShift: '1h', }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -500,7 +531,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T00:00:00.000Z","to":"2020-10-14T02:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T00:00:00.000Z","to":"2020-10-14T02:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' )}` ); }); @@ -518,6 +549,8 @@ describe('createSpanLinkFactory', () => { { key: 'k8s.pod', value: 'pod' }, ], }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); expect(createLink).toBeDefined(); @@ -535,7 +568,7 @@ describe('createSpanLinkFactory', () => { expect(links).toBeDefined(); expect(links!.metricLinks![0]!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A"}],"panelsState":{}}' + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A"}]}' )}` ); }); @@ -620,10 +653,8 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toContain( - encodeURIComponent( - `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` - ) + expect(decodeURIComponent(linkDef!.href)).toContain( + `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` ); }); @@ -660,7 +691,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -686,17 +717,15 @@ describe('createSpanLinkFactory', () => { const linkDef = links?.logLinks?.[0]; expect(linkDef).toBeDefined(); - expect(linkDef!.href).toBe( - `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` - )}` + expect(decodeURIComponent(linkDef!.href)).toBe( + `/explore?left={"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` ); }); it('should format one tag correctly', () => { const createLink = setupSpanLinkFactory( { - tags: ['ip'], + tags: [{ key: 'ip' }], }, searchUID ); @@ -714,7 +743,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -722,7 +751,7 @@ describe('createSpanLinkFactory', () => { it('should format multiple tags correctly', () => { const createLink = setupSpanLinkFactory( { - tags: ['ip', 'hostname'], + tags: [{ key: 'ip' }, { key: 'hostname' }], }, searchUID ); @@ -743,7 +772,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); @@ -751,8 +780,7 @@ describe('createSpanLinkFactory', () => { it('handles renamed tags', () => { const createLink = setupSpanLinkFactory( { - mapTagNamesEnabled: true, - mappedTags: [ + tags: [ { key: 'service.name', value: 'service' }, { key: 'k8s.pod.name', value: 'pod' }, ], @@ -776,18 +804,73 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` )}` ); }); }); + + describe('custom query', () => { + beforeAll(() => { + setDataSourceSrv({ + getInstanceSettings() { + return { uid: 'loki1_uid', name: 'loki1', type: 'loki' } as unknown as DataSourceInstanceSettings; + }, + } as unknown as DataSourceSrv); + + setLinkSrv(new LinkSrv()); + setTemplateSrv(new TemplateSrv()); + }); + + it('interpolates custom query correctly', () => { + const createLink = setupSpanLinkFactory({ + tags: [ + { key: 'service.name', value: 'service' }, + { key: 'k8s.pod.name', value: 'pod' }, + ], + customQuery: true, + query: '{${__tags}} |="${__span.tags["service.name"]}" |="${__trace.traceId}"', + }); + expect(createLink).toBeDefined(); + const links = createLink!( + createTraceSpan({ + process: { + serviceName: 'service', + tags: [ + { key: 'service.name', value: 'serviceName' }, + { key: 'k8s.pod.name', value: 'podName' }, + ], + }, + }) + ); + + const linkDef = links?.logLinks?.[0]; + expect(linkDef).toBeDefined(); + expect(decodeURIComponent(linkDef!.href)).toContain( + '"queries":' + + JSON.stringify([{ expr: '{service="serviceName", pod="podName"} |="serviceName" |="trace1"', refId: '' }]) + ); + }); + + it('does not return a link if variables are not matched', () => { + const createLink = setupSpanLinkFactory({ + tags: [{ key: 'service.name', value: 'service' }], + customQuery: true, + query: '{${__tags}} |="${__span.tags["service.name"]}" |="${__trace.id}"', + }); + expect(createLink).toBeDefined(); + const links = createLink!(createTraceSpan()); + expect(links?.logLinks).toBeUndefined(); + }); + }); }); -function setupSpanLinkFactory(options: Partial = {}, datasourceUid = 'lokiUid') { +function setupSpanLinkFactory(options: Partial = {}, datasourceUid = 'lokiUid') { const splitOpenFn = jest.fn(); return createSpanLinkFactory({ splitOpenFn, traceToLogsOptions: { + customQuery: false, datasourceUid, ...options, }, @@ -796,6 +879,8 @@ function setupSpanLinkFactory(options: Partial = {}, datasou href: `${traceId}-${spanId}`, } as unknown as LinkModel; }, + trace: dummyTraceData, + dataFrame: dummyDataFrame, }); } diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 29e963ab53a..3edbdf50e31 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -1,32 +1,35 @@ -import { SpanLinks } from '@jaegertracing/jaeger-ui-components/src/types/links'; +import { property } from 'lodash'; import React from 'react'; import { DataFrame, DataLink, - DataQuery, DataSourceInstanceSettings, DataSourceJsonData, dateTime, Field, - KeyValue, LinkModel, mapInternalLinkToExplore, rangeUtil, + ScopedVars, SplitOpen, TimeRange, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { Icon } from '@grafana/ui'; -import { SpanLinkFunc, TraceSpan } from '@jaegertracing/jaeger-ui-components'; -import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; +import { TraceToLogsOptionsV2 } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricQuery, TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types'; +import { variableRegex } from '../../variables/utils'; import { getFieldLinksForExplore } from '../utils/links'; +import { SpanLinkFunc, Trace, TraceSpan } from './components'; +import { SpanLinks } from './components/types/links'; + /** * This is a factory for the link creator. It returns the function mainly so it can return undefined in which case * the trace view won't create any links and to capture the datasource and split function making it easier to memoize @@ -38,19 +41,44 @@ export function createSpanLinkFactory({ traceToMetricsOptions, dataFrame, createFocusSpanLink, + trace, }: { splitOpenFn: SplitOpen; - traceToLogsOptions?: TraceToLogsOptions; + traceToLogsOptions?: TraceToLogsOptionsV2; traceToMetricsOptions?: TraceToMetricsOptions; dataFrame?: DataFrame; createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel; + trace: Trace; }): SpanLinkFunc | undefined { - if (!dataFrame || dataFrame.fields.length === 1 || !dataFrame.fields.some((f) => Boolean(f.config.links?.length))) { + if (!dataFrame) { + return undefined; + } + + let scopedVars = scopedVarsFromTrace(trace); + const hasLinks = dataFrame.fields.some((f) => Boolean(f.config.links?.length)); + const legacyFormat = dataFrame.fields.length === 1; + + if (legacyFormat || !hasLinks) { // if the dataframe contains just a single blob of data (legacy format) or does not have any links configured, // let's try to use the old legacy path. - return legacyCreateSpanLinkFactory(splitOpenFn, traceToLogsOptions, traceToMetricsOptions, createFocusSpanLink); - } else { + // TODO: This was mainly a backward compatibility thing but at this point can probably be removed. + return legacyCreateSpanLinkFactory( + splitOpenFn, + // We need this to make the types happy but for this branch of code it does not matter which field we supply. + dataFrame.fields[0], + traceToLogsOptions, + traceToMetricsOptions, + createFocusSpanLink, + scopedVars + ); + } + + if (hasLinks) { return function SpanLink(span: TraceSpan): SpanLinks | undefined { + scopedVars = { + ...scopedVars, + ...scopedVarsFromSpan(span), + }; // We should be here only if there are some links in the dataframe const field = dataFrame.fields.find((f) => Boolean(f.config.links?.length))!; try { @@ -60,6 +88,7 @@ export function createSpanLinkFactory({ splitOpenFn, range: getTimeRangeFromSpan(span), dataFrame, + vars: scopedVars, }); return { @@ -68,6 +97,7 @@ export function createSpanLinkFactory({ href: links[0].href, onClick: links[0].onClick, content: , + field: links[0].origin, }, ], }; @@ -78,13 +108,22 @@ export function createSpanLinkFactory({ } }; } + + return undefined; } +/** + * Default keys to use when there are no configured tags. + */ +const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod'].map((k) => ({ key: k })); + function legacyCreateSpanLinkFactory( splitOpenFn: SplitOpen, - traceToLogsOptions?: TraceToLogsOptions, + field: Field, + traceToLogsOptions?: TraceToLogsOptionsV2, traceToMetricsOptions?: TraceToMetricsOptions, - createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel + createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel, + scopedVars?: ScopedVars ) { let logsDataSourceSettings: DataSourceInstanceSettings | undefined; if (traceToLogsOptions?.datasourceUid) { @@ -98,59 +137,89 @@ function legacyCreateSpanLinkFactory( } return function SpanLink(span: TraceSpan): SpanLinks { + scopedVars = { + ...scopedVars, + ...scopedVarsFromSpan(span), + }; const links: SpanLinks = { traceLinks: [] }; - // This is reusing existing code from derived fields which may not be ideal match so some data is a bit faked at - // the moment. Issue is that the trace itself isn't clearly mapped to dataFrame (right now it's just a json blob - // inside a single field) so the dataLinks as config of that dataFrame abstraction breaks down a bit and we do - // it manually here instead of leaving it for the data source to supply the config. - let dataLink: DataLink | undefined; + let query: DataQuery | undefined; + let tags = ''; - // Get logs link + // TODO: This should eventually move into specific data sources and added to the data frame as we no longer use the + // deprecated blob format and we can map the link easily in data frame. if (logsDataSourceSettings && traceToLogsOptions) { + const customQuery = traceToLogsOptions.customQuery ? traceToLogsOptions.query : undefined; + const tagsToUse = traceToLogsOptions.tags || defaultKeys; switch (logsDataSourceSettings?.type) { case 'loki': - dataLink = getLinkForLoki(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse); + query = getQueryForLoki(span, traceToLogsOptions, tags, customQuery); break; case 'grafana-splunk-datasource': - dataLink = getLinkForSplunk(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse, { joinBy: ' ' }); + query = getQueryForSplunk(span, traceToLogsOptions, tags, customQuery); break; case 'elasticsearch': - dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); - break; case 'grafana-opensearch-datasource': - dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); + tags = getFormattedTags(span, tagsToUse, { labelValueSign: ':', joinBy: ' AND ' }); + query = getQueryForElasticsearchOrOpensearch(span, traceToLogsOptions, tags, customQuery); break; } - if (dataLink) { - const link = mapInternalLinkToExplore({ - link: dataLink, - internalLink: dataLink.internal!, - scopedVars: {}, - range: getTimeRangeFromSpan( - span, - { - startMs: traceToLogsOptions.spanStartTimeShift - ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) - : 0, - endMs: traceToLogsOptions.spanEndTimeShift - ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) - : 0, - }, - isSplunkDS - ), - field: {} as Field, - onClickFn: splitOpenFn, - replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), - }); - - links.logLinks = [ - { - href: link.href, - onClick: link.onClick, - content: , + // query can be false in case the simple UI tag mapping is used but none of them are present in the span. + // For custom query, this is always defined and we check if the interpolation matched all variables later on. + if (query) { + const dataLink: DataLink = { + title: logsDataSourceSettings.name, + url: '', + internal: { + datasourceUid: logsDataSourceSettings.uid, + datasourceName: logsDataSourceSettings.name, + query, }, - ]; + }; + + scopedVars = { + ...scopedVars, + __tags: { + text: 'Tags', + value: tags, + }, + }; + + // Check if all variables are defined and don't show if they aren't. This is usually handled by the + // getQueryFor* functions but this is for case of custom query supplied by the user. + if (dataLinkHasAllVariablesDefined(dataLink.internal!.query, scopedVars)) { + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal!, + scopedVars: scopedVars, + range: getTimeRangeFromSpan( + span, + { + startMs: traceToLogsOptions.spanStartTimeShift + ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) + : 0, + endMs: traceToLogsOptions.spanEndTimeShift + ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) + : 0, + }, + isSplunkDS + ), + field: {} as Field, + onClickFn: splitOpenFn, + replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), + }); + + links.logLinks = [ + { + href: link.href, + onClick: link.onClick, + content: , + field, + }, + ]; + } } } @@ -158,7 +227,7 @@ function legacyCreateSpanLinkFactory( if (metricsDataSourceSettings && traceToMetricsOptions?.queries) { links.metricLinks = []; for (const query of traceToMetricsOptions.queries) { - const expr = buildMetricsQuery(query, traceToMetricsOptions?.tags, span); + const expr = buildMetricsQuery(query, traceToMetricsOptions?.tags || [], span); const dataLink: DataLink = { title: metricsDataSourceSettings.name, url: '', @@ -194,6 +263,7 @@ function legacyCreateSpanLinkFactory( href: link.href, onClick: link.onClick, content: , + field, }); } } @@ -213,6 +283,7 @@ function legacyCreateSpanLinkFactory( title: reference.span ? reference.span.operationName : 'View linked span', content: , onClick: link.onClick, + field: link.origin, }); } } @@ -226,6 +297,7 @@ function legacyCreateSpanLinkFactory( title: reference.span ? reference.span.operationName : 'View linked span', content: , onClick: link.onClick, + field: link.origin, }); } } @@ -234,56 +306,34 @@ function legacyCreateSpanLinkFactory( }; } -/** - * Default keys to use when there are no configured tags. - */ -const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod']; -function getLinkForLoki(span: TraceSpan, options: TraceToLogsOptions, dataSourceSettings: DataSourceInstanceSettings) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; +function getQueryForLoki( + span: TraceSpan, + options: TraceToLogsOptionsV2, + tags: string, + customQuery?: string +): LokiQuery | undefined { + const { filterByTraceID, filterBySpanID } = options; - // In order, try to use mapped tags -> tags -> default tags - const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; - // Build tag portion of query - const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { - if (mapTagNamesEnabled) { - const keyValue = (keysToCheck as KeyValue[]).find((keyValue: KeyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}="${tag.value}"`); - } - } else { - if ((keysToCheck as string[]).includes(tag.key)) { - acc.push(`${tag.key}="${tag.value}"`); - } - } - return acc; - }, [] as string[]); + if (customQuery) { + return { expr: customQuery, refId: '' }; + } - // If no tags found, return undefined to prevent an invalid Loki query - if (!tags.length) { + if (!tags) { return undefined; } - let expr = `{${tags.join(', ')}}`; + + let expr = '{${__tags}}'; if (filterByTraceID && span.traceID) { - expr += ` |="${span.traceID}"`; + expr += ' |="${__span.traceId}"'; } if (filterBySpanID && span.spanID) { - expr += ` |="${span.spanID}"`; + expr += ' |="${__span.spanId}"'; } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - expr: expr, - refId: '', - }, - }, + return { + expr: expr, + refId: '', }; - - return dataLink; } // we do not have access to the dataquery type for opensearch, @@ -296,114 +346,93 @@ interface ElasticsearchOrOpensearchQuery extends DataQuery { }>; } -function getLinkForElasticsearchOrOpensearch( +function getQueryForElasticsearchOrOpensearch( span: TraceSpan, - options: TraceToLogsOptions, - dataSourceSettings: DataSourceInstanceSettings -) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; - const tags = [...span.process.tags, ...span.tags].reduce((acc: string[], tag) => { - if (mapTagNamesEnabled && mappedTags?.length) { - const keysToCheck = mappedTags; - const keyValue = keysToCheck.find((keyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}:"${tag.value}"`); - } - } else { - const keysToCheck = keys?.length ? keys : defaultKeys; - if (keysToCheck.includes(tag.key)) { - acc.push(`${tag.key}:"${tag.value}"`); - } - } - return acc; - }, []); + options: TraceToLogsOptionsV2, + tags: string, + customQuery?: string +): ElasticsearchOrOpensearchQuery { + const { filterByTraceID, filterBySpanID } = options; + if (customQuery) { + return { + query: customQuery, + refId: '', + metrics: [{ id: '1', type: 'logs' }], + }; + } let queryArr = []; if (filterBySpanID && span.spanID) { - queryArr.push(`"${span.spanID}"`); + queryArr.push('"${__span.spanId}"'); } if (filterByTraceID && span.traceID) { - queryArr.push(`"${span.traceID}"`); + queryArr.push('"${__span.traceId}"'); } - if (tags.length > 0) { - for (const tag of tags) { - queryArr.push(tag); - } + if (tags) { + queryArr.push('${__tags}'); } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - query: queryArr.join(' AND '), - refId: '', - metrics: [ - { - id: '1', - type: 'logs', - }, - ], - }, - }, + return { + query: queryArr.join(' AND '), + refId: '', + metrics: [{ id: '1', type: 'logs' }], }; - - return dataLink; } -function getLinkForSplunk( - span: TraceSpan, - options: TraceToLogsOptions, - dataSourceSettings: DataSourceInstanceSettings -) { - const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; +function getQueryForSplunk(span: TraceSpan, options: TraceToLogsOptionsV2, tags: string, customQuery?: string) { + const { filterByTraceID, filterBySpanID } = options; - // In order, try to use mapped tags -> tags -> default tags - const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; - // Build tag portion of query - const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { - if (mapTagNamesEnabled) { - const keyValue = (keysToCheck as KeyValue[]).find((keyValue: KeyValue) => keyValue.key === tag.key); - if (keyValue) { - acc.push(`${keyValue.value ? keyValue.value : keyValue.key}="${tag.value}"`); - } - } else { - if ((keysToCheck as string[]).includes(tag.key)) { - acc.push(`${tag.key}="${tag.value}"`); - } - } - return acc; - }, [] as string[]); + if (customQuery) { + return { query: customQuery, refId: '' }; + } let query = ''; - if (tags.length > 0) { - query += `${tags.join(' ')}`; + if (tags) { + query += '${__tags}'; } if (filterByTraceID && span.traceID) { - query += ` "${span.traceID}"`; + query += ' "${__span.traceId}"'; } if (filterBySpanID && span.spanID) { - query += ` "${span.spanID}"`; + query += ' "${__span.spanId}"'; } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - query: query, - refId: '', - }, - }, - } as DataLink; + return { + query: query, + refId: '', + }; +} - return dataLink; +/** + * Creates a string representing all the tags already formatted for use in the query. The tags are filtered so that + * only intersection of tags that exist in a span and tags that you want are serialized into the string. + */ +function getFormattedTags( + span: TraceSpan, + tags: Array<{ key: string; value?: string }>, + { labelValueSign = '=', joinBy = ', ' }: { labelValueSign?: string; joinBy?: string } = {} +) { + // In order, try to use mapped tags -> tags -> default tags + // Build tag portion of query + return [ + ...span.process.tags, + ...span.tags, + { key: 'spanId', value: span.spanID }, + { key: 'traceId', value: span.traceID }, + { key: 'name', value: span.operationName }, + { key: 'duration', value: span.duration }, + ] + .map((tag) => { + const keyValue = tags.find((keyValue) => keyValue.key === tag.key); + if (keyValue) { + return `${keyValue.value ? keyValue.value : keyValue.key}${labelValueSign}"${tag.value}"`; + } + return undefined; + }) + .filter((v) => Boolean(v)) + .join(joinBy); } /** @@ -442,9 +471,13 @@ function getTimeRangeFromSpan( } // Interpolates span attributes into trace to metric query, or returns default query -function buildMetricsQuery(query: TraceToMetricQuery, tags: Array> = [], span: TraceSpan): string { +function buildMetricsQuery( + query: TraceToMetricQuery, + tags: Array<{ key: string; value?: string }> = [], + span: TraceSpan +): string { if (!query.query) { - return `histogram_quantile(0.5, sum(rate(tempo_spanmetrics_latency_bucket{operation="${span.operationName}"}[5m])) by (le))`; + return `histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service="${span.process.serviceName}"}[5m])) by (le))`; } let expr = query.query; @@ -464,3 +497,112 @@ function buildMetricsQuery(query: TraceToMetricQuery, tags: Array(query: T, scopedVars: ScopedVars): boolean { + const vars = getVariablesMapInTemplate(getStringsFromObject(query), scopedVars); + return Object.values(vars).every((val) => val !== undefined); +} + +function getStringsFromObject(obj: T): string { + let acc = ''; + for (const k of Object.keys(obj)) { + // Honestly not sure how to type this to make TS happy. + // @ts-ignore + if (typeof obj[k] === 'string') { + // @ts-ignore + acc += ' ' + obj[k]; + // @ts-ignore + } else if (typeof obj[k] === 'object' && obj[k] !== null) { + // @ts-ignore + acc += ' ' + getStringsFromObject(obj[k]); + } + } + return acc; +} + +function getVariablesMapInTemplate(target: string, scopedVars: ScopedVars): Record { + const regex = new RegExp(variableRegex); + const values: Record = {}; + + target.replace(regex, (match, var1, var2, fmt2, var3, fieldPath) => { + const variableName = var1 || var2 || var3; + values[variableName] = getVariableValue(variableName, fieldPath, scopedVars); + + // Don't care about the result anyway + return ''; + }); + + return values; +} + +function getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars): VarValue { + const scopedVar = scopedVars[variableName]; + if (!scopedVar) { + return undefined; + } + + if (fieldPath) { + // @ts-ignore ScopedVars are typed in way that I don't think this is possible to type correctly. + return property(fieldPath)(scopedVar.value); + } + + return scopedVar.value; +} diff --git a/packages/jaeger-ui-components/typings/custom.d.ts b/public/app/features/explore/TraceView/custom.d.ts similarity index 85% rename from packages/jaeger-ui-components/typings/custom.d.ts rename to public/app/features/explore/TraceView/custom.d.ts index dd70a67124d..9695782217f 100644 --- a/packages/jaeger-ui-components/typings/custom.d.ts +++ b/public/app/features/explore/TraceView/custom.d.ts @@ -12,15 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// For inlined envvars -declare const process: { - env: { - NODE_ENV: string; - REACT_APP_GA_DEBUG?: string; - REACT_APP_VSN_STATE?: string; - }; -}; - declare module 'combokeys' { export default class Combokeys { constructor(element: HTMLElement); diff --git a/public/app/features/explore/TraceView/useChildrenState.test.ts b/public/app/features/explore/TraceView/useChildrenState.test.ts index 1d6dca9c200..ab462c1ec48 100644 --- a/public/app/features/explore/TraceView/useChildrenState.test.ts +++ b/public/app/features/explore/TraceView/useChildrenState.test.ts @@ -1,7 +1,6 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { TraceSpan } from '@jaegertracing/jaeger-ui-components'; - +import { TraceSpan } from './components'; import { useChildrenState } from './useChildrenState'; describe('useChildrenState', () => { diff --git a/public/app/features/explore/TraceView/useChildrenState.ts b/public/app/features/explore/TraceView/useChildrenState.ts index ef8607bf9ef..1bfca3611d6 100644 --- a/public/app/features/explore/TraceView/useChildrenState.ts +++ b/public/app/features/explore/TraceView/useChildrenState.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -import { TraceSpan } from '@jaegertracing/jaeger-ui-components'; +import { TraceSpan } from './components'; /** * Children state means whether spans are collapsed or not. Also provides some functions to manipulate that state. diff --git a/public/app/features/explore/TraceView/useDetailState.test.ts b/public/app/features/explore/TraceView/useDetailState.test.ts index ae941f924f5..e9454bd1091 100644 --- a/public/app/features/explore/TraceView/useDetailState.test.ts +++ b/public/app/features/explore/TraceView/useDetailState.test.ts @@ -1,8 +1,8 @@ -import { TraceLog } from '@jaegertracing/jaeger-ui-components/src/types/trace'; import { act, renderHook } from '@testing-library/react-hooks'; import { DataFrame } from '@grafana/data'; +import { TraceLog } from './components/types/trace'; import { useDetailState } from './useDetailState'; const sampleFrame: DataFrame = { diff --git a/public/app/features/explore/TraceView/useDetailState.ts b/public/app/features/explore/TraceView/useDetailState.ts index 63400a6f2f7..e3e4df4c75a 100644 --- a/public/app/features/explore/TraceView/useDetailState.ts +++ b/public/app/features/explore/TraceView/useDetailState.ts @@ -1,9 +1,9 @@ -import { TraceLog, TraceSpanReference } from '@jaegertracing/jaeger-ui-components/src/types/trace'; import { useCallback, useState, useEffect } from 'react'; import { DataFrame } from '@grafana/data'; -import { DetailState } from '@jaegertracing/jaeger-ui-components'; +import { DetailState } from './components'; +import { TraceLog, TraceSpanReference } from './components/types/trace'; /** * Keeps state of the span detail. This means whether span details are open but also state of each detail subitem * like logs or tags. diff --git a/public/app/features/explore/TraceView/useSearch.test.ts b/public/app/features/explore/TraceView/useSearch.test.ts index ce5ceddbeb7..a4a0f537172 100644 --- a/public/app/features/explore/TraceView/useSearch.test.ts +++ b/public/app/features/explore/TraceView/useSearch.test.ts @@ -1,7 +1,6 @@ import { act, renderHook } from '@testing-library/react-hooks'; -import { TraceSpan } from '@jaegertracing/jaeger-ui-components'; - +import { TraceSpan } from './components'; import { useSearch } from './useSearch'; describe('useSearch', () => { diff --git a/public/app/features/explore/TraceView/useSearch.ts b/public/app/features/explore/TraceView/useSearch.ts index 6574ac5659b..38550370c17 100644 --- a/public/app/features/explore/TraceView/useSearch.ts +++ b/public/app/features/explore/TraceView/useSearch.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; -import { filterSpans, TraceSpan } from '@jaegertracing/jaeger-ui-components'; +import { filterSpans, TraceSpan } from './components'; /** * Controls the state of search input that highlights spans if they match the search string. diff --git a/public/app/features/explore/TraceView/useViewRange.ts b/public/app/features/explore/TraceView/useViewRange.ts index e617f488ebe..f577beb0b38 100644 --- a/public/app/features/explore/TraceView/useViewRange.ts +++ b/public/app/features/explore/TraceView/useViewRange.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -import { ViewRangeTimeUpdate, ViewRange } from '@jaegertracing/jaeger-ui-components'; +import { ViewRangeTimeUpdate, ViewRange } from './components'; /** * Controls state of the zoom function that can be used through minimap in header or on the timeline. ViewRange contains diff --git a/public/app/features/explore/TraceView/utils/transform.ts b/public/app/features/explore/TraceView/utils/transform.ts index 7300566121f..e7f1582af83 100644 --- a/public/app/features/explore/TraceView/utils/transform.ts +++ b/public/app/features/explore/TraceView/utils/transform.ts @@ -1,5 +1,6 @@ import { DataFrame, DataFrameView, TraceSpanRow } from '@grafana/data'; -import { Trace, TraceProcess, TraceResponse, transformTraceData } from '@jaegertracing/jaeger-ui-components'; + +import { Trace, TraceProcess, TraceResponse, transformTraceData } from '../components'; export function transformDataFrames(frame?: DataFrame): Trace | null { if (!frame) { diff --git a/public/app/features/explore/state/datasource.test.ts b/public/app/features/explore/state/datasource.test.ts index 01a28e8dd95..606db7151f4 100644 --- a/public/app/features/explore/state/datasource.test.ts +++ b/public/app/features/explore/state/datasource.test.ts @@ -1,4 +1,5 @@ -import { DataQuery, DataSourceApi } from '@grafana/data'; +import { DataSourceApi } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; import { ExploreId, ExploreItemState } from 'app/types'; import { updateDatasourceInstanceAction, datasourceReducer } from './datasource'; diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 20cff532e12..b3d7878864d 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -4,16 +4,15 @@ import { AnyAction } from 'redux'; import { EventBusExtended, - DataQuery, ExploreUrlState, TimeRange, HistoryItem, DataSourceApi, ExplorePanelsState, PreferredVisualisationType, - DataSourceRef, } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; import { DEFAULT_RANGE, getQueryKeys, diff --git a/public/app/features/explore/state/helpers.ts b/public/app/features/explore/state/helpers.ts index fe3fdba14c6..511e24acc81 100644 --- a/public/app/features/explore/state/helpers.ts +++ b/public/app/features/explore/state/helpers.ts @@ -27,6 +27,7 @@ export const createDefaultInitialState = () => { getSupportedSupplementaryQueryTypes: jest .fn() .mockImplementation(() => [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample]), + getSupplementaryQuery: jest.fn(), meta: { id: 'something', }, diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts index 84447f65b41..9697e2eb596 100644 --- a/public/app/features/explore/state/history.ts +++ b/public/app/features/explore/state/history.ts @@ -1,7 +1,8 @@ import { AnyAction, createAction } from '@reduxjs/toolkit'; -import { DataQuery, HistoryItem } from '@grafana/data'; +import { HistoryItem } from '@grafana/data'; import { config, logError } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils'; import store from 'app/core/store'; import { diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index e052cd8fabf..750dce944c1 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -1,8 +1,9 @@ import { createAction } from '@reduxjs/toolkit'; import { AnyAction } from 'redux'; -import { DataQuery, ExploreUrlState, serializeStateToUrlParam, SplitOpenOptions, UrlQueryMap } from '@grafana/data'; +import { ExploreUrlState, serializeStateToUrlParam, SplitOpenOptions, UrlQueryMap } from '@grafana/data'; import { DataSourceSrv, locationService } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore'; import { PanelModel } from 'app/features/dashboard/state'; import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index bfb5223a52b..4c461a0fd78 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -5,7 +5,6 @@ import { assertIsDefined } from 'test/helpers/asserts'; import { ArrayVector, - DataQuery, DataQueryResponse, DataSourceApi, DataSourceJsonData, @@ -15,6 +14,7 @@ import { RawTimeRange, SupplementaryQueryType, } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; @@ -413,6 +413,7 @@ describe('reducer', () => { SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample, ], + getSupplementaryQuery: jest.fn(), }, }, }, diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index ea89c07ac4a..531ebeb5374 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -6,7 +6,6 @@ import { mergeMap, throttleTime } from 'rxjs/operators'; import { AbsoluteTimeRange, - DataQuery, DataQueryErrorType, DataQueryResponse, DataSourceApi, @@ -22,6 +21,7 @@ import { hasLogsVolumeSupport, } from '@grafana/data'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { buildQueryTransaction, ensureQueries, diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 00bd72b69bc..868c82df53a 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -3,7 +3,6 @@ import { isEmpty, isObject, mapValues, omitBy } from 'lodash'; import { AbsoluteTimeRange, DataSourceApi, - DataSourceRef, EventBusExtended, ExploreUrlState, getDefaultTimeRange, @@ -11,6 +10,7 @@ import { LoadingState, PanelData, } from '@grafana/data'; +import { DataSourceRef } from '@grafana/schema'; import { ExplorePanelData } from 'app/types'; import { ExploreItemState } from 'app/types/explore'; diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 4273fd994a0..f8354ed809c 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -9,9 +9,9 @@ import { getDisplayProcessor, PanelData, standardTransformers, - DataQuery, } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { dataFrameToLogsModel } from '../../../core/logsModel'; import { refreshIntervalToSortOrder } from '../../../core/utils/explore'; diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 1c60871ed87..3d111b947f9 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -147,7 +147,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -166,7 +166,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -194,7 +194,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' )}` ); }); @@ -236,7 +236,7 @@ describe('getFieldLinksForExplore', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}],"panelsState":{}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' )}` ); }); diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index a8e507a0b59..6c50fb4e312 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -24,6 +24,12 @@ const dataLinkHasRequiredPermissions = (link: DataLink) => { return !link.internal || contextSrv.hasAccessToExplore(); }; +/** + * Check if every variable in the link has a value. If not this returns false. If there are no variables in the link + * this will return true. + * @param link + * @param scopedVars + */ const dataLinkHasAllVariablesDefined = (link: DataLink, scopedVars: ScopedVars) => { let hasAllRequiredVarDefined = true; diff --git a/public/app/features/explore/utils/supplementaryQueries.ts b/public/app/features/explore/utils/supplementaryQueries.ts index cef393d2401..bb00172523f 100644 --- a/public/app/features/explore/utils/supplementaryQueries.ts +++ b/public/app/features/explore/utils/supplementaryQueries.ts @@ -17,7 +17,7 @@ export const loadSupplementaryQueries = (): SupplementaryQueries => { // We default to true for all supp queries let supplementaryQueries: SupplementaryQueries = { [SupplementaryQueryType.LogsVolume]: { enabled: true }, - [SupplementaryQueryType.LogsSample]: { enabled: true }, + [SupplementaryQueryType.LogsSample]: { enabled: false }, }; for (const type of supplementaryQueryTypes) { @@ -36,6 +36,11 @@ export const loadSupplementaryQueries = (): SupplementaryQueries => { } } + // We want to skip LogsSample and default it to false for now to trigger it only on user action + if (type === SupplementaryQueryType.LogsSample) { + continue; + } + // Only if "false" value in local storage, we disable it const shouldBeEnabled = store.get(getSupplementaryQuerySettingKey(type)); if (shouldBeEnabled === 'false') { diff --git a/public/app/features/inspector/InspectDataTab.test.tsx b/public/app/features/inspector/InspectDataTab.test.tsx index 3355a8b8cef..0dc821cb758 100644 --- a/public/app/features/inspector/InspectDataTab.test.tsx +++ b/public/app/features/inspector/InspectDataTab.test.tsx @@ -6,10 +6,6 @@ import { DataFrame, FieldType } from '@grafana/data'; import { InspectDataTab } from './InspectDataTab'; -// the mock below gets rid of this warning from recompose: -// Warning: React.createFactory() is deprecated and will be removed in a future major release. Consider using JSX or use React.createElement() directly instead. -jest.mock('@jaegertracing/jaeger-ui-components', () => ({})); - const createProps = (propsOverride?: Partial>) => { const defaultProps = { isLoading: false, diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index b615a008614..d270b75511c 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -113,7 +113,8 @@ export class InspectDataTab extends PureComponent { }; exportTracesAsJson = () => { - const { data, panel } = this.props; + const { data, panel, app } = this.props; + if (!data) { return; } @@ -123,16 +124,19 @@ export class InspectDataTab extends PureComponent { if (df.meta?.preferredVisualisationType !== 'trace') { continue; } + let traceFormat = 'otlp'; switch (df.meta?.custom?.traceFormat) { case 'jaeger': { let res = transformToJaeger(new MutableDataFrame(df)); downloadAsJson(res, (panel ? panel.getDisplayTitle() : 'Explore') + '-traces'); + traceFormat = 'jaeger'; break; } case 'zipkin': { let res = transformToZipkin(new MutableDataFrame(df)); downloadAsJson(res, (panel ? panel.getDisplayTitle() : 'Explore') + '-traces'); + traceFormat = 'zipkin'; break; } case 'otlp': @@ -142,11 +146,24 @@ export class InspectDataTab extends PureComponent { break; } } + + reportInteraction('grafana_traces_download_traces_clicked', { + app, + grafana_version: config.buildInfo.version, + trace_format: traceFormat, + location: 'inspector', + }); } }; exportServiceGraph = () => { - const { data, panel } = this.props; + const { data, panel, app } = this.props; + reportInteraction('grafana_traces_download_service_graph_clicked', { + app, + grafana_version: config.buildInfo.version, + location: 'inspector', + }); + if (!data) { return; } diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index d4b44d4c0f2..ac0813f6b6d 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -12,6 +12,7 @@ import { GrafanaTheme2, CoreApp, DataFrame, + DataSourceWithLogsContextSupport, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { styleMixins, withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui'; @@ -53,6 +54,7 @@ interface Props extends Themeable2 { onClickFilterOutLabel?: (key: string, value: string) => void; onContextClick?: () => void; getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise; + getLogRowContextUi?: (row: LogRowModel) => React.ReactNode; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; showContextToggle?: (row?: LogRowModel) => boolean; onClickShowField?: (key: string) => void; @@ -143,7 +145,9 @@ class UnThemedLogRow extends PureComponent { errors?: LogRowContextQueryErrors, hasMoreContextRows?: HasMoreContextRows, updateLimit?: () => void, - logsSortOrder?: LogsSortOrder | null + logsSortOrder?: LogsSortOrder | null, + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'], + runContextQuery?: () => void ) { const { getRows, @@ -230,6 +234,8 @@ class UnThemedLogRow extends PureComponent { getRows={getRows} errors={errors} hasMoreContextRows={hasMoreContextRows} + getLogRowContextUi={getLogRowContextUi} + runContextQuery={runContextQuery} updateLimit={updateLimit} context={context} contextIsOpen={showContext} @@ -267,14 +273,26 @@ class UnThemedLogRow extends PureComponent { render() { const { showContext } = this.state; - const { logsSortOrder, row, getRowContext } = this.props; + const { logsSortOrder, row, getRowContext, getLogRowContextUi } = this.props; if (showContext) { return ( <> - {({ result, errors, hasMoreContextRows, updateLimit, logsSortOrder }) => { - return <>{this.renderLogRow(result, errors, hasMoreContextRows, updateLimit, logsSortOrder)}; + {({ result, errors, hasMoreContextRows, updateLimit, runContextQuery, logsSortOrder }) => { + return ( + <> + {this.renderLogRow( + result, + errors, + hasMoreContextRows, + updateLimit, + logsSortOrder, + getLogRowContextUi, + runContextQuery + )} + + ); }} diff --git a/public/app/features/logs/components/LogRowContext.tsx b/public/app/features/logs/components/LogRowContext.tsx index 68383069231..9c3e76283fa 100644 --- a/public/app/features/logs/components/LogRowContext.tsx +++ b/public/app/features/logs/components/LogRowContext.tsx @@ -1,8 +1,16 @@ import { css, cx } from '@emotion/css'; -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; -import { DataQueryError, GrafanaTheme2, LogRowModel, LogsSortOrder, textUtil } from '@grafana/data'; +import { + DataQueryError, + GrafanaTheme2, + LogRowModel, + LogsSortOrder, + textUtil, + DataSourceWithLogsContextSupport, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, Button, ClickOutsideWrapper, CustomScrollbar, IconButton, List, useStyles2 } from '@grafana/ui'; import { LogMessageAnsi } from './LogMessageAnsi'; @@ -22,9 +30,14 @@ interface LogRowContextProps { logsSortOrder?: LogsSortOrder | null; onOutsideClick: (method: string) => void; onLoadMoreContext: () => void; + runContextQuery?: () => void; + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; } -const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) => { +const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean, datasourceUiHeight?: number) => { + if (!config.featureToggles.logsContextDatasourceUi || !datasourceUiHeight) { + datasourceUiHeight = 0; + } /** * This is workaround for displaying uncropped context when we have unwrapping log messages. * We are using margins to correctly position context. Because non-wrapped logs have always 1 line of log @@ -34,7 +47,8 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) const headerHeight = 40; const logsHeight = 220; - const contextHeight = headerHeight + logsHeight; + const contextHeight = datasourceUiHeight + headerHeight + logsHeight; + const bottomContextHeight = headerHeight + logsHeight; const width = wrapLogMessage ? '100%' : '75%'; const afterContext = wrapLogMessage ? css` @@ -55,6 +69,9 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) width: css` width: ${width}; `, + bottomContext: css` + height: ${bottomContextHeight}px; + `, commonStyles: css` position: absolute; height: ${contextHeight}px; @@ -73,6 +90,13 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) align-items: center; background: ${theme.colors.background.canvas}; `, + datasourceUi: css` + height: ${datasourceUiHeight}px; + padding: ${theme.spacing(0, 1.25)}; + display: flex; + align-items: center; + background: ${theme.colors.background.canvas}; + `, top: css` border-radius: 0 0 ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)}; box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; @@ -132,13 +156,14 @@ interface LogRowContextGroupHeaderProps { shouldScrollToBottom?: boolean; canLoadMoreRows?: boolean; logsSortOrder?: LogsSortOrder | null; + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; + runContextQuery?: () => void; } interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps { rows: Array; groupPosition: LogGroupPosition; className?: string; error?: string; - logsSortOrder?: LogsSortOrder | null; } const LogRowContextGroupHeader: React.FunctionComponent = ({ @@ -148,8 +173,16 @@ const LogRowContextGroupHeader: React.FunctionComponent { - const { header, headerButton } = useStyles2(getLogRowContextStyles); + const [height, setHeight] = useState(0); + const datasourceUiRef = React.createRef(); + const { + datasourceUi: dsUi, + header, + headerButton, + } = useStyles2((theme) => getLogRowContextStyles(theme, undefined, height)); // determine the position in time for this LogGroup by taking the ordering of // logs and position of the component itself into account. @@ -162,21 +195,56 @@ const LogRowContextGroupHeader: React.FunctionComponent + new ResizeObserver((entries) => { + for (let entry of entries) { + setHeight(entry.contentRect.height); + } + }), + [] + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useLayoutEffect(() => { + // observe the first child of the ref, which is the datasource controlled component and varies in height + // TODO: this is a bit of a hack and we can remove this as soon as we move back from the absolute positioned context + const child = datasourceUiRef.current?.children.item(0); + if (child) { + resizeObserver.observe(child); + } + return () => { + resizeObserver.disconnect(); + }; + }, [datasourceUiRef, resizeObserver]); + } + return ( -
- - Showing {rows.length} lines {logGroupPosition} match. - - {(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && ( - + <> + {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( +
+ {getLogRowContextUi(row, runContextQuery)} +
)} -
+
+ + Showing {rows.length} lines {logGroupPosition} match. + + {(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && ( + + )} +
+ ); }; @@ -190,8 +258,10 @@ export const LogRowContextGroup: React.FunctionComponent { - const { commonStyles, logs } = useStyles2(getLogRowContextStyles); + const { commonStyles, logs, bottomContext } = useStyles2(getLogRowContextStyles); const [scrollTop, setScrollTop] = useState(0); const [scrollHeight, setScrollHeight] = useState(0); @@ -243,10 +313,12 @@ export const LogRowContextGroup: React.FunctionComponent +
{/* When displaying "after" context */} {shouldScrollToBottom && !error && }
@@ -284,9 +356,11 @@ export const LogRowContext: React.FunctionComponent = ({ errors, onOutsideClick, onLoadMoreContext, + runContextQuery: runContextQuery, hasMoreContextRows, wrapLogMessage, logsSortOrder, + getLogRowContextUi, }) => { useEffect(() => { const handleEscKeyDown = (e: KeyboardEvent): void => { @@ -321,6 +395,8 @@ export const LogRowContext: React.FunctionComponent = ({ onLoadMoreContext={onLoadMoreContext} groupPosition={LogGroupPosition.Top} logsSortOrder={logsSortOrder} + getLogRowContextUi={getLogRowContextUi} + runContextQuery={runContextQuery} /> )} diff --git a/public/app/features/logs/components/LogRowContextProvider.tsx b/public/app/features/logs/components/LogRowContextProvider.tsx index 814ffb632d4..e79d9476903 100644 --- a/public/app/features/logs/components/LogRowContextProvider.tsx +++ b/public/app/features/logs/components/LogRowContextProvider.tsx @@ -34,6 +34,7 @@ export interface HasMoreContextRows { interface ResultType { data: string[][]; errors: string[]; + doNotCheckForMore?: boolean; } interface LogRowContextProviderProps { @@ -45,6 +46,7 @@ interface LogRowContextProviderProps { errors: LogRowContextQueryErrors; hasMoreContextRows: HasMoreContextRows; updateLimit: () => void; + runContextQuery: () => void; limit: number; logsSortOrder?: LogsSortOrder | null; }) => JSX.Element; @@ -55,7 +57,7 @@ export const getRowContexts = async ( row: LogRowModel, limit: number, logsSortOrder?: LogsSortOrder | null -) => { +): Promise => { const promises = [ getRowContext(row, { limit, @@ -159,6 +161,8 @@ export const LogRowContextProvider: React.FunctionComponent(); + // React Hook that resolves two promises every time the limit prop changes // First promise fetches limit number of rows backwards in time from a specific point in time // Second promise fetches limit number of rows forwards in time from a specific point in time @@ -166,40 +170,46 @@ export const LogRowContextProvider: React.FunctionComponent { + setResults(value); + }, [value]); + // React Hook that performs a side effect every time the value (from useAsync hook) prop changes // The side effect changes the result state with the response from the useAsync hook // The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result useEffect(() => { - if (value) { + if (results) { setResult((currentResult) => { - let hasMoreLogsBefore = true, - hasMoreLogsAfter = true; + if (!results.doNotCheckForMore) { + let hasMoreLogsBefore = true, + hasMoreLogsAfter = true; - const currentResultBefore = currentResult?.data[0]; - const currentResultAfter = currentResult?.data[1]; - const valueBefore = value.data[0]; - const valueAfter = value.data[1]; + const currentResultBefore = currentResult?.data[0]; + const currentResultAfter = currentResult?.data[1]; + const valueBefore = results.data[0]; + const valueAfter = results.data[1]; - // checks if there are more log rows in a given direction - // if after fetching additional rows the length of result is the same, - // we can assume there are no logs in that direction within a given time range - if (currentResult && (!valueBefore || currentResultBefore.length === valueBefore.length)) { - hasMoreLogsBefore = false; + // checks if there are more log rows in a given direction + // if after fetching additional rows the length of result is the same, + // we can assume there are no logs in that direction within a given time range + if (currentResult && (!valueBefore || currentResultBefore.length === valueBefore.length)) { + hasMoreLogsBefore = false; + } + + if (currentResult && (!valueAfter || currentResultAfter.length === valueAfter.length)) { + hasMoreLogsAfter = false; + } + + setHasMoreContextRows({ + before: hasMoreLogsBefore, + after: hasMoreLogsAfter, + }); } - if (currentResult && (!valueAfter || currentResultAfter.length === valueAfter.length)) { - hasMoreLogsAfter = false; - } - - setHasMoreContextRows({ - before: hasMoreLogsBefore, - after: hasMoreLogsAfter, - }); - - return value; + return results; }); } - }, [value]); + }, [results]); return children({ result: { @@ -221,6 +231,11 @@ export const LogRowContextProvider: React.FunctionComponent { + const results = await getRowContexts(getRowContext, row, limit, logsSortOrder); + results.doNotCheckForMore = true; + setResults(results); + }, limit, logsSortOrder, }); diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index e0fd1d69fb7..380e32feb05 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -4,7 +4,14 @@ import React, { PureComponent } from 'react'; import Highlighter from 'react-highlight-words'; import tinycolor from 'tinycolor2'; -import { LogRowModel, findHighlightChunksInText, GrafanaTheme2, LogsSortOrder, CoreApp } from '@grafana/data'; +import { + LogRowModel, + findHighlightChunksInText, + GrafanaTheme2, + LogsSortOrder, + CoreApp, + DataSourceWithLogsContextSupport, +} from '@grafana/data'; import { withTheme2, Themeable2, IconButton, Tooltip } from '@grafana/ui'; import { LogMessageAnsi } from './LogMessageAnsi'; @@ -26,9 +33,11 @@ interface Props extends Themeable2 { app?: CoreApp; scrollElement?: HTMLDivElement; showContextToggle?: (row?: LogRowModel) => boolean; + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; getRows: () => LogRowModel[]; onToggleContext: (method: string) => void; updateLimit?: () => void; + runContextQuery?: () => void; logsSortOrder?: LogsSortOrder | null; } @@ -154,6 +163,7 @@ class UnThemedLogRowMessage extends PureComponent { errors, hasMoreContextRows, updateLimit, + runContextQuery, context, contextIsOpen, showRowMenu, @@ -163,6 +173,7 @@ class UnThemedLogRowMessage extends PureComponent { app, logsSortOrder, showContextToggle, + getLogRowContextUi, } = this.props; const style = getLogRowStyles(theme, row.logLevel); @@ -191,6 +202,8 @@ class UnThemedLogRowMessage extends PureComponent { {contextIsOpen && context && ( void; onClickFilterOutLabel?: (key: string, value: string) => void; getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise; + getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi']; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; @@ -128,6 +130,7 @@ class UnThemedLogRows extends PureComponent { onLogRowHover, app, scrollElement, + getLogRowContextUi, } = this.props; const { renderAll, contextIsOpen } = this.state; const { logsRowsTable } = getLogRowStyles(theme); @@ -156,6 +159,7 @@ class UnThemedLogRows extends PureComponent { key={row.uid} getRows={getRows} getRowContext={getRowContext} + getLogRowContextUi={getLogRowContextUi} row={row} showContextToggle={showContextToggle} showRowMenu={!contextIsOpen} @@ -187,6 +191,7 @@ class UnThemedLogRows extends PureComponent { key={row.uid} getRows={getRows} getRowContext={getRowContext} + getLogRowContextUi={getLogRowContextUi} row={row} showContextToggle={showContextToggle} showRowMenu={!contextIsOpen} diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 3b8af6a23d2..9eb4d9003aa 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -5,7 +5,7 @@ import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { StoreState, ThunkResult } from 'app/types'; -import { invalidatePluginInCache } from '../../pluginCacheBuster'; +import { invalidatePluginInCache } from '../../systemjsPlugins/pluginCacheBuster'; import { getRemotePlugins, getPluginErrors, diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 63b933f302e..05a92288f8d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -32,7 +32,9 @@ import * as ticks from 'app/core/utils/ticks'; import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; -import { locateWithCache, registerPluginInCache } from './pluginCacheBuster'; +import { locateFromCDN, translateForCDN } from './systemjsPlugins/pluginCDN'; +import { fetchCSS, locateCSS } from './systemjsPlugins/pluginCSS'; +import { locateWithCache, registerPluginInCache } from './systemjsPlugins/pluginCacheBuster'; // Help the 6.4 to 6.5 migration // The base classes were moved from @grafana/ui to @grafana/data @@ -43,7 +45,12 @@ grafanaUI.DataSourcePlugin = grafanaData.DataSourcePlugin; grafanaUI.AppPlugin = grafanaData.AppPlugin; grafanaUI.DataSourceApi = grafanaData.DataSourceApi; +grafanaRuntime.SystemJS.registry.set('css', grafanaRuntime.SystemJS.newModule({ locate: locateCSS, fetch: fetchCSS })); grafanaRuntime.SystemJS.registry.set('plugin-loader', grafanaRuntime.SystemJS.newModule({ locate: locateWithCache })); +grafanaRuntime.SystemJS.registry.set( + 'cdn-loader', + grafanaRuntime.SystemJS.newModule({ locate: locateFromCDN, translate: translateForCDN }) +); grafanaRuntime.SystemJS.config({ baseURL: 'public', @@ -52,10 +59,12 @@ grafanaRuntime.SystemJS.config({ plugins: { defaultExtension: 'js', }, + 'plugin-cdn': { + defaultExtension: 'js', + }, }, map: { text: 'vendor/plugin-text/text.js', - css: 'vendor/plugin-css/css.js', }, meta: { '/*': { @@ -63,6 +72,14 @@ grafanaRuntime.SystemJS.config({ authorization: true, loader: 'plugin-loader', }, + '*.css': { + loader: 'css', + }, + 'plugin-cdn/*': { + esModule: true, + authorization: false, + loader: 'cdn-loader', + }, }, }); diff --git a/public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts b/public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts new file mode 100644 index 00000000000..6abb0699e7d --- /dev/null +++ b/public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts @@ -0,0 +1,105 @@ +import { config } from '@grafana/runtime'; + +import { translateForCDN, extractPluginNameVersionFromUrl } from './pluginCDN'; +describe('Plugin CDN', () => { + describe('translateForCDN', () => { + const load = { + name: 'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js', + address: 'http://my-host.com/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js', + source: 'public/plugins/grafana-worldmap-panel/template.html', + metadata: { + extension: '', + deps: [], + format: 'amd', + loader: 'cdn-loader', + encapsulateGlobal: false, + cjsRequireDetection: true, + cjsDeferDepsExecute: false, + esModule: true, + authorization: false, + }, + }; + config.pluginsCDNBaseURL = 'http://my-host.com'; + + it('should update the default local path to use the CDN path', () => { + const translatedLoad = translateForCDN({ + ...load, + source: 'public/plugins/grafana-worldmap-panel/template.html', + }); + expect(translatedLoad).toBe( + 'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html' + ); + }); + + it('should replace the default path in a multi-line source code', () => { + const source = ` + const a = "public/plugins/grafana-worldmap-panel/template.html"; + const img = ""; + `; + const expectedSource = ` + const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html"; + const img = ""; + `; + const translatedLoad = translateForCDN({ ...load, source }); + expect(translatedLoad).toBe(expectedSource); + }); + + it('should cater for local paths starting with a slash', () => { + const source = ` + const a = "/public/plugins/grafana-worldmap-panel/template.html"; + const img = ""; + `; + const expectedSource = ` + const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html"; + const img = ""; + `; + const translatedLoad = translateForCDN({ ...load, source }); + expect(translatedLoad).toBe(expectedSource); + }); + + it('should cater for a particular path', () => { + const source = ` + .getJSON( + "public/plugins/grafana-worldmap-panel/data/" + + this.panel.locationData + + ".json" + ) + `; + const expectedSource = ` + .getJSON( + "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/" + + this.panel.locationData + + ".json" + ) + `; + const translatedLoad = translateForCDN({ ...load, source }); + expect(translatedLoad).toBe(expectedSource); + }); + + it('should replace sourcemap locations', () => { + const source = ` + Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])}); + //# sourceMappingURL=module.js.map + `; + const expectedSource = ` + Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])}); + //# sourceMappingURL=http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js.map + `; + const translatedLoad = translateForCDN({ ...load, source }); + expect(translatedLoad).toBe(expectedSource); + }); + }); + + describe('extractPluginNameVersionFromUrl', () => { + it('should extract the plugin name and version from a path', () => { + const source = + 'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js'; + const expected = { + name: 'grafana-worldmap-panel', + version: '0.3.3', + }; + const expectedExtractedPluginDeets = extractPluginNameVersionFromUrl(source); + expect(expectedExtractedPluginDeets).toEqual(expected); + }); + }); +}); diff --git a/public/app/features/plugins/systemjsPlugins/pluginCDN.ts b/public/app/features/plugins/systemjsPlugins/pluginCDN.ts new file mode 100644 index 00000000000..22e33f0e1ef --- /dev/null +++ b/public/app/features/plugins/systemjsPlugins/pluginCDN.ts @@ -0,0 +1,29 @@ +import { config } from '@grafana/runtime'; + +import type { SystemJSLoad } from './types'; + +export function extractPluginNameVersionFromUrl(address: string) { + const path = new URL(address).pathname; + const match = path.split('/'); + return { name: match[3], version: match[4] }; +} + +export function locateFromCDN(load: SystemJSLoad) { + const { address } = load; + const pluginPath = address.split('/public/plugin-cdn/'); + return `${config.pluginsCDNBaseURL}/${pluginPath[1]}`; +} + +export function translateForCDN(load: SystemJSLoad) { + const { name, version } = extractPluginNameVersionFromUrl(load.name); + const baseAddress = `${config.pluginsCDNBaseURL}/${name}/${version}`; + + load.source = load.source.replace(/(\/?)(public\/plugins)/g, `${baseAddress}/$2`); + load.source = load.source.replace(/(["|'])(plugins\/.+.css)(["|'])/g, `$1${baseAddress}/public/$2$3`); + load.source = load.source.replace( + /(\/\/#\ssourceMappingURL=)(.+)\.map/g, + `$1${baseAddress}/public/plugins/${name}/$2.map` + ); + + return load.source; +} diff --git a/public/app/features/plugins/systemjsPlugins/pluginCSS.ts b/public/app/features/plugins/systemjsPlugins/pluginCSS.ts new file mode 100644 index 00000000000..a97002ab8ed --- /dev/null +++ b/public/app/features/plugins/systemjsPlugins/pluginCSS.ts @@ -0,0 +1,75 @@ +import { noop } from 'lodash'; + +import { config } from '@grafana/runtime'; + +import type { SystemJSLoad } from './types'; + +/* + Locate: Overrides the location of the plugin resource + Plugins that import css use relative paths in Systemjs.register dependency list. + Rather than attempt to resolve it in the pluginCDN systemjs plugin let SystemJS resolve it to origin + then we can replace the "baseUrl" with the "cdnHost". + */ +export function locateCSS(load: SystemJSLoad) { + if (load.metadata.loader === 'cdn-loader' && load.address.startsWith(`${location.origin}/public/plugin-cdn`)) { + load.address = load.address.replace(`${location.origin}/public/plugin-cdn`, config.pluginsCDNBaseURL); + } + return load.address; +} + +/* + Fetch: Called with second argument representing default fetch function, has full control of fetch output. + Plugins that have external CSS will use this plugin to load their custom styles +*/ +export function fetchCSS(load: SystemJSLoad) { + const links = document.getElementsByTagName('link'); + const linkHrefs: string[] = Array.from(links).map((link) => link.href); + + // dont reload styles loaded in the head + if (linkHrefs.includes(load.address)) { + return ''; + } + + return loadCSS(load.address); +} + +const bust = '?_cache=' + Date.now(); +const waitSeconds = 100; + +function loadCSS(url: string) { + return new Promise(function (resolve, reject) { + const timeout = setTimeout(function () { + reject('Unable to load CSS'); + }, waitSeconds * 1000); + const _callback = function (error?: string | Error) { + clearTimeout(timeout); + link.onload = link.onerror = noop; + setTimeout(function () { + if (error) { + reject(error); + } else { + resolve(''); + } + }, 7); + }; + const link = document.createElement('link'); + link.type = 'text/css'; + link.rel = 'stylesheet'; + link.href = url; + + // Don't cache bust plugins loaded from cdn. + if (!link.href.startsWith(config.pluginsCDNBaseURL)) { + link.href = link.href + bust; + } + + link.onload = function () { + _callback(); + }; + + link.onerror = function (event) { + _callback(event instanceof ErrorEvent ? event.message : new Error('Error loading CSS file.')); + }; + + document.head.appendChild(link); + }); +} diff --git a/public/app/features/plugins/tests/pluginCacheBuster.test.ts b/public/app/features/plugins/systemjsPlugins/pluginCacheBuster.test.ts similarity index 98% rename from public/app/features/plugins/tests/pluginCacheBuster.test.ts rename to public/app/features/plugins/systemjsPlugins/pluginCacheBuster.test.ts index f263cbd3d04..c8d046bcaa6 100644 --- a/public/app/features/plugins/tests/pluginCacheBuster.test.ts +++ b/public/app/features/plugins/systemjsPlugins/pluginCacheBuster.test.ts @@ -1,6 +1,7 @@ -import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from '../pluginCacheBuster'; import * as pluginSettings from '../pluginSettings'; +import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from './pluginCacheBuster'; + describe('PluginCacheBuster', () => { const now = 12345; diff --git a/public/app/features/plugins/pluginCacheBuster.ts b/public/app/features/plugins/systemjsPlugins/pluginCacheBuster.ts similarity index 94% rename from public/app/features/plugins/pluginCacheBuster.ts rename to public/app/features/plugins/systemjsPlugins/pluginCacheBuster.ts index 5df396c1562..af18eb43dd8 100644 --- a/public/app/features/plugins/pluginCacheBuster.ts +++ b/public/app/features/plugins/systemjsPlugins/pluginCacheBuster.ts @@ -1,4 +1,4 @@ -import { clearPluginSettingsCache } from './pluginSettings'; +import { clearPluginSettingsCache } from '../pluginSettings'; const cache: Record = {}; const initializedAt: number = Date.now(); diff --git a/public/app/features/plugins/systemjsPlugins/types.ts b/public/app/features/plugins/systemjsPlugins/types.ts new file mode 100644 index 00000000000..3a7356084d0 --- /dev/null +++ b/public/app/features/plugins/systemjsPlugins/types.ts @@ -0,0 +1,16 @@ +export type SystemJSLoad = { + address: string; + metadata: { + authorization: boolean; + cjsDeferDepsExecute: boolean; + cjsRequireDetection: boolean; + crossOrigin?: boolean; + encapsulateGlobal: boolean; + esModule: boolean; + integrity?: string; + loader: string; + scriptLoad?: boolean; + }; + name: string; + source: string; +}; diff --git a/public/app/features/plugins/utils.test.ts b/public/app/features/plugins/utils.test.ts index bca1665ffb6..b79a5840279 100644 --- a/public/app/features/plugins/utils.test.ts +++ b/public/app/features/plugins/utils.test.ts @@ -69,6 +69,13 @@ describe('buildPluginSectionNav', () => { expect(result?.node.text).toBe('page2'); }); + it('Should only set the most specific match as active (not the parents)', () => { + config.featureToggles.topnav = true; + const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2'); + expect(result?.main.children![0].children![1].active).toBe(true); + expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active + }); + it('Should set app section to active', () => { config.featureToggles.topnav = true; const result = buildPluginSectionNav(appsSection, null, '/a/plugin1'); diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index c28a58e70df..145431cc039 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -49,6 +49,8 @@ export function buildPluginSectionNav( return page; } + // Check if there is already an active page found with with a more specific url (possibly a child of the current page) + // (In this case we bail out early and don't mark the parent as active) if (activePage && (activePage.url?.length ?? 0) > (page.url?.length ?? 0)) { return page; } @@ -58,15 +60,20 @@ export function buildPluginSectionNav( } activePage = { ...page, active: true }; + return activePage; } // Find and set active page copiedPluginNavSection.children = (copiedPluginNavSection?.children ?? []).map((child) => { if (child.children) { + // Doing this here to make sure that first we check if any of the children is active + // (In case yes, then the check for the parent will not mark it as active) + const children = child.children.map((pluginPage) => setPageToActive(pluginPage, currentUrl)); + return { ...setPageToActive(child, currentUrl), - children: child.children.map((pluginPage) => setPageToActive(pluginPage, currentUrl)), + children, }; } diff --git a/public/app/features/query-library/components/QueryEditorDrawerHeader.tsx b/public/app/features/query-library/components/QueryEditorDrawerHeader.tsx index 2f3fe1d4a68..fea76c8c8e6 100644 --- a/public/app/features/query-library/components/QueryEditorDrawerHeader.tsx +++ b/public/app/features/query-library/components/QueryEditorDrawerHeader.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; import React, { useEffect, useRef, useState } from 'react'; -import InlineSVG from 'react-inlinesvg/esm'; import { GrafanaTheme2 } from '@grafana/data/src'; import { Button, HorizontalGroup, Icon, IconName, useStyles2 } from '@grafana/ui'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { useAppNotification } from '../../../core/copy/appNotification'; import { SavedQuery } from '../api/SavedQueriesApi'; @@ -150,7 +150,7 @@ export const QueryEditorDrawerHeader = ({ savedQuery, onDismiss, onSavedQueryCha
{option.src ? ( - + ) : ( )} diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index ac028cac25d..ccf32ab5d08 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -63,8 +63,10 @@ interface Props { } interface State { + /** DatasourceUid or ds variable expression used to resolve current datasource */ loadedDataSourceIdentifier?: string | null; datasource: DataSourceApi | null; + datasourceUid?: string | null; hasTextEditMode: boolean; data?: PanelData; isOpen?: boolean; @@ -238,17 +240,17 @@ export class QueryEditorRow extends PureComponent { + isWaitingForDatasourceToLoad(): boolean { // if we not yet have loaded the datasource in state the // ds in props and the ds in state will have different values. - return this.props.dataSource.uid !== this.state.datasource?.uid; - }; + return this.props.dataSource.uid !== this.state.loadedDataSourceIdentifier; + } renderPluginEditor = () => { const { query, onChange, queries, onRunQuery, onAddQuery, app = CoreApp.PanelEditor, history } = this.props; const { datasource, data } = this.state; - if (this.waitingForDatasourceToLoad()) { + if (this.isWaitingForDatasourceToLoad()) { return null; } diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 05eec743cb8..2cd7579f6cf 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -34,7 +34,6 @@ interface Props { onQueryCopied?: () => void; onQueryRemoved?: () => void; onQueryToggled?: (queryStatus?: boolean | undefined) => void; - onDatasourceChange?: (dataSource: DataSourceInstanceSettings, query: DataQuery) => void; } export class QueryEditorRows extends PureComponent { @@ -59,10 +58,6 @@ export class QueryEditorRows extends PureComponent { onDataSourceChange(dataSource: DataSourceInstanceSettings, index: number) { const { queries, onQueriesChange } = this.props; - if (this.props.onDatasourceChange) { - this.props.onDatasourceChange(dataSource, queries[index]); - } - onQueriesChange( queries.map((item, itemIndex) => { if (itemIndex !== index) { diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx index 489e3088a23..d7c50662682 100644 --- a/public/app/features/scenes/scenes/demo.tsx +++ b/public/app/features/scenes/scenes/demo.tsx @@ -8,6 +8,7 @@ import { SceneToolbarInput, SceneDataNode, } from '@grafana/scenes'; +import { TestDataQueryType } from 'app/plugins/datasource/testdata/dataquery.gen'; import { panelBuilders } from '../builders/panelBuilders'; import { DashboardScene } from '../dashboard/DashboardScene'; @@ -69,7 +70,7 @@ export function getScenePanelRepeaterTest(): DashboardScene { const queryRunner = getQueryRunnerWithRandomWalkQuery({ seriesCount: 2, alias: '__server_names', - scenarioId: 'random_walk', + scenarioId: TestDataQueryType.RandomWalk, }); return new DashboardScene({ diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index 052f5ee9903..75080b77a45 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -1,4 +1,5 @@ import { VizPanel, SceneGridRow, SceneTimePicker, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; +import { TestDataQueryType } from 'app/plugins/datasource/testdata/dataquery.gen'; import { DashboardScene } from '../dashboard/DashboardScene'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -18,7 +19,7 @@ export function getGridWithMultipleTimeRanges(): DashboardScene { children: [ new SceneGridRow({ $timeRange: row1TimeRange, - $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: 'random_walk_table' }), + $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: TestDataQueryType.RandomWalkTable }), title: 'Row A - has its own query, last year time range', key: 'Row A', isCollapsed: true, diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index 051c539c61a..dfd8d69494a 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -1,4 +1,5 @@ import { VizPanel, SceneGridRow, SceneTimePicker, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; +import { TestDataQueryType } from 'app/plugins/datasource/testdata/dataquery.gen'; import { DashboardScene } from '../dashboard/DashboardScene'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -12,7 +13,7 @@ export function getGridWithMultipleData(): DashboardScene { children: [ new SceneGridRow({ $timeRange: new SceneTimeRange(), - $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: 'random_walk_table' }), + $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: TestDataQueryType.RandomWalkTable }), title: 'Row A - has its own query', key: 'Row A', isCollapsed: true, diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts index cce0057e0a7..684013e24d2 100644 --- a/public/app/features/scenes/scenes/queries.ts +++ b/public/app/features/scenes/scenes/queries.ts @@ -1,8 +1,8 @@ import { QueryRunnerState, SceneQueryRunner } from '@grafana/scenes'; -import { TestDataQuery } from 'app/plugins/datasource/testdata/types'; +import { TestData } from 'app/plugins/datasource/testdata/dataquery.gen'; export function getQueryRunnerWithRandomWalkQuery( - overrides?: Partial, + overrides?: Partial, queryRunnerOverrides?: Partial ) { return new SceneQueryRunner({ diff --git a/public/app/features/search/components/DashboardActions.tsx b/public/app/features/search/components/DashboardActions.tsx index 6ba55030d23..1828c2154bf 100644 --- a/public/app/features/search/components/DashboardActions.tsx +++ b/public/app/features/search/components/DashboardActions.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; -import { config } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Menu, Dropdown, Button, Icon } from '@grafana/ui'; import { t } from 'app/core/internationalization'; @@ -30,13 +30,31 @@ export const DashboardActions: FC = ({ folderUid, canCreateFolders = fals return ( {canCreateDashboards && ( - + + reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new'), from: '/dashboards' }) + } + /> )} {canCreateFolders && (config.featureToggles.nestedFolders || !folderUid) && ( - + + reportInteraction('grafana_menu_item_clicked', { url: actionUrl('new_folder'), from: '/dashboards' }) + } + /> )} {canCreateDashboards && ( - + + reportInteraction('grafana_menu_item_clicked', { url: actionUrl('import'), from: '/dashboards' }) + } + /> )} ); diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index 615dfa35493..2580147cf36 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -11,6 +11,7 @@ export const GENERAL_FOLDER_UID = 'general'; export const GENERAL_FOLDER_TITLE = 'General'; export const SEARCH_PANELS_LOCAL_STORAGE_KEY = 'grafana.search.include.panels'; export const SEARCH_SELECTED_LAYOUT = 'grafana.search.layout'; +export const SEARCH_SELECTED_SORT = 'grafana.search.sort'; export const TYPE_KIND_MAP: { [key: string]: DashboardSearchItemType } = { dashboard: DashboardSearchItemType.DashDB, folder: DashboardSearchItemType.DashFolder, diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index 415941bdbb0..27fe51c9ab5 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -20,7 +20,7 @@ if (config.featureToggles.dashboardPreviews) { interface Props { onLayoutChange: (layout: SearchLayout) => void; - onSortChange: (value: SelectableValue) => void; + onSortChange: (value?: string) => void; onStarredFilterChange?: (event: FormEvent) => void; onTagFilterChange: (tags: string[]) => void; getTagOptions: () => Promise; @@ -106,8 +106,8 @@ export const ActionRow: FC = ({ /> )} onSortChange(change.value)} + value={state.sort} getSortOptions={getSortOptions} placeholder={sortPlaceholder} isClearable diff --git a/public/app/features/search/page/reporting.ts b/public/app/features/search/page/reporting.ts index 083a340b49e..9c708ac069f 100644 --- a/public/app/features/search/page/reporting.ts +++ b/public/app/features/search/page/reporting.ts @@ -6,7 +6,7 @@ import { EventTrackingNamespace, SearchLayout } from '../types'; interface QueryProps { layout: SearchLayout; starred: boolean; - sortValue: string; + sortValue?: string; query: string; tagCount: number; includePanels?: boolean; diff --git a/public/app/features/search/state/SearchStateManager.ts b/public/app/features/search/state/SearchStateManager.ts index e04ae3106c7..0b4d93d185c 100644 --- a/public/app/features/search/state/SearchStateManager.ts +++ b/public/app/features/search/state/SearchStateManager.ts @@ -1,13 +1,12 @@ import { debounce } from 'lodash'; import { FormEvent } from 'react'; -import { SelectableValue } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import store from 'app/core/store'; -import { SEARCH_PANELS_LOCAL_STORAGE_KEY, SEARCH_SELECTED_LAYOUT } from '../constants'; +import { SEARCH_PANELS_LOCAL_STORAGE_KEY, SEARCH_SELECTED_LAYOUT, SEARCH_SELECTED_SORT } from '../constants'; import { reportDashboardListViewed, reportSearchFailedQueryInteraction, @@ -21,10 +20,10 @@ import { parseRouteParams } from '../utils'; export const initialState: SearchState = { query: '', tag: [], - sort: null, starred: false, layout: SearchLayout.Folders, - prevSort: null, + sort: undefined, + prevSort: undefined, eventTrackingNamespace: 'dashboard_search', }; @@ -113,7 +112,11 @@ export class SearchStateManager extends StateManagerBase { this.setStateAndDoSearch({ starred: false }); }; - onSortChange = (sort: SelectableValue | null) => { + onSortChange = (sort: string | undefined) => { + if (sort) { + localStorage.setItem(SEARCH_SELECTED_SORT, sort); + } + if (this.state.layout === SearchLayout.Folders) { this.setStateAndDoSearch({ sort, layout: SearchLayout.List }); } else { @@ -125,7 +128,7 @@ export class SearchStateManager extends StateManagerBase { localStorage.setItem(SEARCH_SELECTED_LAYOUT, layout); if (this.state.sort && layout === SearchLayout.Folders) { - this.setStateAndDoSearch({ layout, prevSort: this.state.sort, sort: null }); + this.setStateAndDoSearch({ layout, prevSort: this.state.sort, sort: undefined }); } else { this.setStateAndDoSearch({ layout, sort: this.state.prevSort }); } @@ -146,7 +149,7 @@ export class SearchStateManager extends StateManagerBase { tags: this.state.tag as string[], ds_uid: this.state.datasource as string, location: this.state.folderUid, // This will scope all results to the prefix - sort: this.state.sort?.value, + sort: this.state.sort, explain: this.state.explain, withAllowedActions: this.state.explain, // allowedActions are currently not used for anything on the UI and added only in `explain` mode starred: this.state.starred, @@ -179,7 +182,7 @@ export class SearchStateManager extends StateManagerBase { const trackingInfo = { layout: this.state.layout, starred: this.state.starred, - sortValue: this.state.sort?.value, + sortValue: this.state.sort, query: this.state.query, tagCount: this.state.tag?.length, includePanels: this.state.includePanels, @@ -227,13 +230,13 @@ export class SearchStateManager extends StateManagerBase { onSearchItemClicked = (e: React.MouseEvent) => { // Clear some filters only if we're not opening a search item in a new tab if (!e.altKey && !e.ctrlKey && !e.metaKey) { - this.setState({ tag: [], starred: false, sort: null, query: '', folderUid: undefined }); + this.setState({ tag: [], starred: false, sort: undefined, query: '', folderUid: undefined }); } reportSearchResultInteraction(this.state.eventTrackingNamespace, { layout: this.state.layout, starred: this.state.starred, - sortValue: this.state.sort?.value, + sortValue: this.state.sort, query: this.state.query, tagCount: this.state.tag?.length, includePanels: this.state.includePanels, @@ -247,7 +250,7 @@ export class SearchStateManager extends StateManagerBase { reportDashboardListViewed(this.state.eventTrackingNamespace, { layout: this.state.layout, starred: this.state.starred, - sortValue: this.state.sort?.value, + sortValue: this.state.sort, query: this.state.query, tagCount: this.state.tag?.length, includePanels: this.state.includePanels, @@ -261,13 +264,14 @@ export function getSearchStateManager() { if (!stateManager) { const selectedLayout = localStorage.getItem(SEARCH_SELECTED_LAYOUT) as SearchLayout; const layout = selectedLayout ?? initialState.layout; + const sort = localStorage.getItem(SEARCH_SELECTED_SORT) ?? undefined; let includePanels = store.getBool(SEARCH_PANELS_LOCAL_STORAGE_KEY, true); if (includePanels) { includePanels = false; } - stateManager = new SearchStateManager({ ...initialState, layout: layout, includePanels }); + stateManager = new SearchStateManager({ ...initialState, layout, sort, includePanels }); } return stateManager; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index a63ef7960a5..ac8f930f094 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -1,6 +1,6 @@ import { Action } from 'redux'; -import { SelectableValue, WithAccessControlMetadata } from '@grafana/data'; +import { WithAccessControlMetadata } from '@grafana/data'; import { QueryResponse } from './service'; @@ -78,9 +78,8 @@ export interface SearchState { starred: boolean; explain?: boolean; // adds debug info datasource?: string; - sort: SelectableValue | null; - // Save sorting data between layouts - prevSort: SelectableValue | null; + sort?: string; + prevSort?: string; // Save sorting data between layouts layout: SearchLayout; result?: QueryResponse; loading?: boolean; diff --git a/public/app/features/search/utils.test.ts b/public/app/features/search/utils.test.ts index 209c9450855..152d5647956 100644 --- a/public/app/features/search/utils.test.ts +++ b/public/app/features/search/utils.test.ts @@ -12,7 +12,6 @@ describe('Search utils', () => { }); it('should return tag as array, if present', () => { - //@ts-ignore const params = { sort: undefined, tag: 'test', query: 'test' }; expect(parseRouteParams(params)).toEqual({ query: 'test', @@ -26,14 +25,6 @@ describe('Search utils', () => { }); }); - it('should return sort as a SelectableValue', () => { - const params: Partial = { sort: 'test' }; - - expect(parseRouteParams(params)).toEqual({ - sort: { value: 'test' }, - }); - }); - it('should prepend folder:{folder} to the query if folder is present', () => { expect(parseRouteParams({ folder: 'current' })).toEqual({ folder: 'current', diff --git a/public/app/features/search/utils.ts b/public/app/features/search/utils.ts index ea5ed3ea274..7c942ec1174 100644 --- a/public/app/features/search/utils.ts +++ b/public/app/features/search/utils.ts @@ -28,16 +28,15 @@ export const getSectionStorageKey = (title = 'General') => { * @param folder */ export const parseRouteParams = (params: UrlQueryMap) => { - const cleanedParams = Object.entries(params).reduce((obj, [key, val]) => { + const cleanedParams = Object.entries(params).reduce>((obj, [key, val]) => { if (!val) { return obj; } else if (key === 'tag' && !Array.isArray(val)) { return { ...obj, tag: [val] as string[] }; - } else if (key === 'sort') { - return { ...obj, sort: { value: val } }; } + return { ...obj, [key]: val }; - }, {} as Partial); + }, {}); if (params.folder) { const folderStr = `folder:${params.folder}`; diff --git a/public/app/features/storage/FileView.tsx b/public/app/features/storage/FileView.tsx index 0405ffe2433..efdf0324e5f 100644 --- a/public/app/features/storage/FileView.tsx +++ b/public/app/features/storage/FileView.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { isString } from 'lodash'; import React, { useMemo } from 'react'; -import SVG from 'react-inlinesvg'; import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { CodeEditor, useStyles2 } from '@grafana/ui'; +import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG'; import { getGrafanaStorage } from './storage'; import { StorageView } from './types'; @@ -55,7 +55,7 @@ export function FileView({ listing, path, onPathChange, view }: Props) { case 'svg': return (
- +
); case 'image': diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx index 2e782cc07a1..9cc6fef9582 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx @@ -6,6 +6,7 @@ import { config } from '@grafana/runtime'; import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource'; import { validLogsQuery, validMetricSearchBuilderQuery } from '../__mocks__/queries'; +import { DEFAULT_LOGS_QUERY_STRING } from '../defaultQueries'; import QueryHeader from './QueryHeader'; @@ -16,11 +17,10 @@ const ds = setupMockedDataSource({ ds.datasource.resources.getRegions = jest.fn().mockResolvedValue([]); describe('QueryHeader', () => { - afterEach(() => { - config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue; - }); - describe('when changing region', () => { + afterEach(() => { + config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue; + }); const { datasource } = setupMockedDataSource(); datasource.resources.getRegions = jest.fn().mockResolvedValue([ { value: 'us-east-2', label: 'us-east-2' }, @@ -117,4 +117,91 @@ describe('QueryHeader', () => { expect(datasource.resources.isMonitoringAccount).not.toHaveBeenCalledWith(); }); }); + + describe('when changing query mode', () => { + const { datasource } = setupMockedDataSource(); + it('should set default log query when switching to log mode', async () => { + const onChange = jest.fn(); + datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(false); + render( + + ); + expect(await screen.findByText('CloudWatch Metrics')).toBeInTheDocument(); + await selectEvent.select(await screen.findByLabelText('Query mode'), 'CloudWatch Logs', { + container: document.body, + }); + expect(onChange).toHaveBeenCalledWith({ + ...validMetricSearchBuilderQuery, + logGroupNames: undefined, + logGroups: [], + queryMode: 'Logs', + sqlExpression: '', + expression: DEFAULT_LOGS_QUERY_STRING, + }); + }); + + it('should set expression to empty when switching to metrics mode', async () => { + const onChange = jest.fn(); + datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(false); + render( + + ); + expect(await screen.findByText('CloudWatch Logs')).toBeInTheDocument(); + await selectEvent.select(await screen.findByLabelText('Query mode'), 'CloudWatch Metrics', { + container: document.body, + }); + expect(onChange).toHaveBeenCalledWith({ + ...validMetricSearchBuilderQuery, + logGroupNames: undefined, + logGroups: [], + sqlExpression: '', + expression: '', + }); + }); + }); + describe('log expression', () => { + const { datasource } = setupMockedDataSource(); + it('should disable run query button when empty', async () => { + const onChange = jest.fn(); + datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(false); + render( + + ); + expect(await screen.findByText('Run queries')).toBeInTheDocument(); + expect(screen.getByText('Run queries').closest('button')).toBeDisabled(); + }); + it('should enable run query button when set', async () => { + const onChange = jest.fn(); + datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(false); + render( + + ); + expect(await screen.findByText('Run queries')).toBeInTheDocument(); + expect(screen.getByText('Run queries').closest('button')).not.toBeDisabled(); + }); + }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx index 76f05ee0468..e5f1e5eb4e6 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx @@ -6,7 +6,8 @@ import { config } from '@grafana/runtime'; import { Badge, Button } from '@grafana/ui'; import { CloudWatchDatasource } from '../datasource'; -import { isCloudWatchMetricsQuery } from '../guards'; +import { DEFAULT_LOGS_QUERY_STRING } from '../defaultQueries'; +import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from '../guards'; import { useIsMonitoringAccount, useRegions } from '../hooks'; import { CloudWatchJsonData, CloudWatchQuery, CloudWatchQueryMode, MetricQueryType } from '../types'; @@ -34,12 +35,19 @@ const QueryHeader: React.FC = ({ const { queryMode, region } = query; const isMonitoringAccount = useIsMonitoringAccount(datasource.resources, query.region); const [regions, regionIsLoading] = useRegions(datasource); + const emptyLogsExpression = isCloudWatchLogsQuery(query) ? !query.expression : false; const onQueryModeChange = ({ value }: SelectableValue) => { if (value && value !== queryMode) { + // reset expression to a default string when the query mode changes + let expression = ''; + if (value === 'Logs') { + expression = DEFAULT_LOGS_QUERY_STRING; + } onChange({ ...datasource.getDefaultQuery(CoreApp.Unknown), ...query, + expression, queryMode: value, }); } @@ -100,7 +108,7 @@ const QueryHeader: React.FC = ({ size="sm" onClick={onRunQuery} icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined} - disabled={data?.state === LoadingState.Loading} + disabled={data?.state === LoadingState.Loading || emptyLogsExpression} > Run queries diff --git a/public/app/plugins/datasource/cloudwatch/defaultQueries.ts b/public/app/plugins/datasource/cloudwatch/defaultQueries.ts index 5eff9bbd8cb..e921f9d75cc 100644 --- a/public/app/plugins/datasource/cloudwatch/defaultQueries.ts +++ b/public/app/plugins/datasource/cloudwatch/defaultQueries.ts @@ -16,13 +16,14 @@ export const DEFAULT_METRICS_QUERY: Omit = { matchExact: true, }; +export const DEFAULT_LOGS_QUERY_STRING = 'fields @timestamp, @message |\n sort @timestamp desc |\n limit 20'; + export const getDefaultLogsQuery = ( defaultLogGroups?: LogGroup[], legacyDefaultLogGroups?: string[] ): Omit => ({ id: '', region: 'default', - expression: '', // in case legacy default log groups have been defined in the ConfigEditor, they will be migrated in the LogGroupsField component or the next time the ConfigEditor is opened. // the migration requires async backend calls, so we don't want to do it here as it would block the UI. logGroupNames: legacyDefaultLogGroups, diff --git a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts index 665d5ab3c33..51cfb3c0883 100644 --- a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts +++ b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts @@ -288,6 +288,11 @@ export class ElasticResponse { continue; } + if (aggDef.type === 'nested') { + this.processBuckets(esAgg, target, seriesList, table, props, depth + 1); + continue; + } + if (depth === maxDepth) { if (aggDef.type === 'date_histogram') { this.processMetrics(esAgg, target, seriesList, props); diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts index b091f5a9c00..c9f8f06ae32 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts @@ -587,6 +587,23 @@ describe('ElasticQueryBuilder', () => { expect(firstLevel.histogram.min_doc_count).toBe('2'); }); + it('with nested', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [ + { + type: 'nested', + field: 'nested_field', + id: '3', + }, + ], + }); + + const firstLevel = query.aggs['3']; + expect(firstLevel.nested.path).toBe('nested_field'); + }); + // This test wasn't migrated, as adhoc variables are going to be interpolated before // Or we need to add this to backend query builder (TBD) it('with adhoc filters', () => { diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts index c79653169bc..ee236a425ca 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts @@ -283,6 +283,10 @@ export class ElasticQueryBuilder { }; break; } + case 'nested': { + esAgg['nested'] = { path: aggDef.field }; + break; + } } nestedAggs.aggs = nestedAggs.aggs || {}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts index 5e230c26b19..a23bd5b58eb 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts @@ -1,6 +1,6 @@ import { bucketAggregationConfig } from './utils'; -export type BucketAggregationType = 'terms' | 'filters' | 'geohash_grid' | 'date_histogram' | 'histogram'; +export type BucketAggregationType = 'terms' | 'filters' | 'geohash_grid' | 'date_histogram' | 'histogram' | 'nested'; interface BaseBucketAggregation { id: string; @@ -62,7 +62,12 @@ interface GeoHashGrid extends BucketAggregationWithField { }; } -export type BucketAggregation = DateHistogram | Histogram | Terms | Filters | GeoHashGrid; +interface Nested extends BucketAggregationWithField { + type: 'nested'; + settings?: {}; +} + +export type BucketAggregation = DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested; export const isBucketAggregationWithField = ( bucketAgg: BucketAggregation | BucketAggregationWithField @@ -74,6 +79,7 @@ export const BUCKET_AGGREGATION_TYPES: BucketAggregationType[] = [ 'terms', 'filters', 'geohash_grid', + 'nested', ]; export const isBucketAggregationType = (s: BucketAggregationType | string): s is BucketAggregationType => diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts index d37876d090e..60ecba4d87e 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts @@ -47,6 +47,11 @@ export const bucketAggregationConfig: BucketsConfiguration = { min_doc_count: '0', }, }, + nested: { + label: 'Nested (experimental)', + requiresField: true, + defaultSettings: {}, + }, }; export const orderByOptions: Array> = [ diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts index 1017464cc04..348a511010d 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts @@ -191,7 +191,7 @@ export const metricAggregationConfig: MetricsConfiguration = { }, }, raw_document: { - label: 'Raw Document (legacy)', + label: 'Raw Document (deprecated)', requiresField: false, isSingleMetric: true, isPipelineAgg: false, diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index eb288ebc49d..93b20ad676d 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { Alert, DataSourceHttpSettings } from '@grafana/ui'; +import { Alert, DataSourceHttpSettings, SecureSocksProxySettings } from '@grafana/ui'; import { config } from 'app/core/config'; import { ElasticsearchOptions } from '../types'; @@ -57,6 +57,10 @@ export const ConfigEditor = (props: Props) => { renderSigV4Editor={} /> + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} + { expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('*'); }); + + describe('getSupplementaryQuery', () => { + let ds: ElasticDatasource; + beforeEach(() => { + ds = getTestContext().ds; + }); + + it('does not return logs volume query for metric query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [{ type: 'filters', settings: { filters: [{ query: 'foo', label: '' }] }, id: '1' }], + query: 'foo="bar"', + }) + ).toEqual(undefined); + }); + + it('returns logs volume query for log query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, { + refId: 'A', + metrics: [{ type: 'logs', id: '1' }], + query: 'foo="bar"', + }) + ).toEqual({ + bucketAggs: [ + { + field: '', + id: '3', + settings: { + interval: 'auto', + min_doc_count: '0', + trimEdges: '0', + }, + type: 'date_histogram', + }, + ], + metrics: [ + { + id: '1', + type: 'count', + }, + ], + query: 'foo="bar"', + refId: 'log-volume-A', + timeField: '', + }); + }); + }); }); describe('getMultiSearchUrl', () => { diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index dfa3f5eeecb..dcdd8e15a8f 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -600,58 +600,80 @@ export class ElasticDatasource return [SupplementaryQueryType.LogsVolume]; } - getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { - const isLogsVolumeAvailable = request.targets.some((target) => { - return target.metrics?.length === 1 && target.metrics[0].type === 'logs'; - }); - if (!isLogsVolumeAvailable) { + getSupplementaryQuery(type: SupplementaryQueryType, query: ElasticsearchQuery): ElasticsearchQuery | undefined { + if (!this.getSupportedSupplementaryQueryTypes().includes(type)) { return undefined; } - const logsVolumeRequest = cloneDeep(request); - logsVolumeRequest.targets = logsVolumeRequest.targets.map((target) => { - const bucketAggs: BucketAggregation[] = []; - const timeField = this.timeField ?? '@timestamp'; - if (this.logLevelField) { + let isQuerySuitable = false; + + switch (type) { + case SupplementaryQueryType.LogsVolume: + // it has to be a logs-producing range-query + isQuerySuitable = !!(query.metrics?.length === 1 && query.metrics[0].type === 'logs'); + if (!isQuerySuitable) { + return undefined; + } + const bucketAggs: BucketAggregation[] = []; + const timeField = this.timeField ?? '@timestamp'; + + if (this.logLevelField) { + bucketAggs.push({ + id: '2', + type: 'terms', + settings: { + min_doc_count: '0', + size: '0', + order: 'desc', + orderBy: '_count', + missing: LogLevel.unknown, + }, + field: this.logLevelField, + }); + } bucketAggs.push({ - id: '2', - type: 'terms', + id: '3', + type: 'date_histogram', settings: { + interval: 'auto', min_doc_count: '0', - size: '0', - order: 'desc', - orderBy: '_count', - missing: LogLevel.unknown, + trimEdges: '0', }, - field: this.logLevelField, + field: timeField, }); + + return { + refId: `${REF_ID_STARTER_LOG_VOLUME}${query.refId}`, + query: query.query, + metrics: [{ type: 'count', id: '1' }], + timeField, + bucketAggs, + }; + + default: + return undefined; + } + } + + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { + const logsVolumeRequest = cloneDeep(request); + const targets = logsVolumeRequest.targets + .map((target) => this.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, target)) + .filter((query): query is ElasticsearchQuery => !!query); + + if (!targets.length) { + return undefined; + } + + return queryLogsVolume( + this, + { ...logsVolumeRequest, targets }, + { + range: request.range, + targets: request.targets, + extractLevel: (dataFrame) => getLogLevelFromKey(dataFrame.name || ''), } - bucketAggs.push({ - id: '3', - type: 'date_histogram', - settings: { - interval: 'auto', - min_doc_count: '0', - trimEdges: '0', - }, - field: timeField, - }); - - const logsVolumeQuery: ElasticsearchQuery = { - refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`, - query: target.query, - metrics: [{ type: 'count', id: '1' }], - timeField, - bucketAggs, - }; - return logsVolumeQuery; - }); - - return queryLogsVolume(this, logsVolumeRequest, { - range: request.range, - targets: request.targets, - extractLevel: (dataFrame) => getLogLevelFromKey(dataFrame.name || ''), - }); + ); } query(request: DataQueryRequest): Observable { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts index 4b8d43f11a8..8cf4faa3f7b 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts @@ -41,6 +41,7 @@ export default function createMockQuery(overrides?: Partial): alias: '', // timeGrains: [], top: '10', + region: '', ...overrides?.azureMonitor, }, }; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts index 98934049c87..8d5cdf15074 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts @@ -5,7 +5,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv'; import createMockQuery from '../__mocks__/query'; import { createTemplateVariables } from '../__mocks__/utils'; -import { singleVariable, subscriptionsVariable } from '../__mocks__/variables'; +import { multiVariable, singleVariable, subscriptionsVariable } from '../__mocks__/variables'; import AzureMonitorDatasource from '../datasource'; import { AzureDataSourceJsonData, AzureMonitorLocationsResponse, AzureQueryType } from '../types'; @@ -122,6 +122,67 @@ describe('AzureMonitorDatasource', () => { }, }); }); + + it('expand template variables in resource groups and names', () => { + const resourceGroup = '$rg'; + const resourceName = '$rn'; + templateSrv.init([ + { + id: 'rg', + name: 'rg', + current: { + value: `rg1,rg2`, + }, + }, + { + id: 'rn', + name: 'rn', + current: { + value: `rn1,rn2`, + }, + }, + ]); + const query = createMockQuery({ + azureMonitor: { + resources: [{ resourceGroup, resourceName }], + }, + }); + const templatedQuery = ctx.ds.azureMonitorDatasource.applyTemplateVariables(query, {}); + expect(templatedQuery).toMatchObject({ + azureMonitor: { + resources: [ + { resourceGroup: 'rg1', resourceName: 'rn1' }, + { resourceGroup: 'rg2', resourceName: 'rn1' }, + { resourceGroup: 'rg1', resourceName: 'rn2' }, + { resourceGroup: 'rg2', resourceName: 'rn2' }, + ], + }, + }); + }); + + it('expand template variables for a region', () => { + const region = '$reg'; + templateSrv.init([ + { + id: 'reg', + name: 'reg', + current: { + value: `eastus`, + }, + }, + ]); + const query = createMockQuery({ + azureMonitor: { + region, + }, + }); + const templatedQuery = ctx.ds.azureMonitorDatasource.applyTemplateVariables(query, {}); + expect(templatedQuery).toMatchObject({ + azureMonitor: { + region: 'eastus', + }, + }); + }); }); describe('When performing getMetricNamespaces', () => { @@ -570,6 +631,41 @@ describe('AzureMonitorDatasource', () => { expect(results[0].value).toEqual('nodeapp'); }); }); + + it('should return multiple resources from a template variable', () => { + const tsrv = new TemplateSrv(); + tsrv.replace = jest + .fn() + .mockImplementation((value: string) => (value === `$${multiVariable.id}` ? 'foo,bar' : value)); + const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv); + ds.azureMonitorDatasource.templateSrv = tsrv; + ds.azureMonitorDatasource.getResource = jest + .fn() + .mockImplementationOnce((path: string) => { + expect(path).toMatch('foo'); + return Promise.resolve(response); + }) + .mockImplementationOnce((path: string) => { + expect(path).toMatch('bar'); + return Promise.resolve({ + value: [ + { + name: resourceGroup + '2', + type: metricNamespace, + }, + ], + }); + }); + return ds + .getResourceNames(subscription, `$${multiVariable.id}`, metricNamespace) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + expect(results[1].text).toEqual('nodeapp2'); + expect(results[1].value).toEqual('nodeapp2'); + }); + }); }); describe('and the metric definition is blobServices', () => { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 16c37b1b2e5..84d8b3bebf9 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -22,6 +22,7 @@ import { AzureMonitorLocations, AzureMonitorProvidersResponse, AzureMonitorLocationsResponse, + AzureGetResourceNamesQuery, } from '../types'; import { routeNames } from '../utils/common'; import migrateQuery from '../utils/migrateQuery'; @@ -98,10 +99,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend ({ - resourceGroup: templateSrv.replace(r.resourceGroup, scopedVars), - resourceName: templateSrv.replace(r.resourceName, scopedVars), - })); + const resources = item.resources?.map((r) => this.replaceTemplateVariables(r, scopedVars)).flat(); const metricNamespace = templateSrv.replace(item.metricNamespace, scopedVars); const customNamespace = templateSrv.replace(item.customNamespace, scopedVars); const timeGrain = templateSrv.replace((item.timeGrain || '').toString(), scopedVars); @@ -127,6 +125,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { - let list: Array<{ text: string; value: string }> = []; - if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) { - list = ResponseParser.parseResourceNames(result, 'microsoft.storage/storageaccounts'); - for (let i = 0; i < list.length; i++) { - list[i].text += '/default'; - list[i].value += '/default'; - } - } else { - list = ResponseParser.parseResourceNames(result, metricNamespace); + async getResourceNames(query: AzureGetResourceNamesQuery, skipToken?: string) { + const promises = this.replaceTemplateVariables(query).map(({ metricNamespace, subscriptionId, resourceGroup }) => { + const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/') + ? 'microsoft.storage/storageaccounts' + : metricNamespace; + let url = `${this.resourcePath}/subscriptions/${subscriptionId}`; + if (resourceGroup) { + url += `/resourceGroups/${resourceGroup}`; } - - if (result.nextLink) { - // If there is a nextLink, we should request more pages - const nextURL = new URL(result.nextLink); - const nextToken = nextURL.searchParams.get('$skiptoken'); - if (!nextToken) { - throw Error('unable to request the next page of resources'); - } - const nextPage = await this.getResourceNames(subscriptionId, resourceGroup, metricNamespace, nextToken); - list = list.concat(nextPage); + url += `/resources?api-version=${this.listByResourceGroupApiVersion}`; + if (validMetricNamespace) { + url += `&$filter=resourceType eq '${validMetricNamespace}'`; } + if (skipToken) { + url += `&$skiptoken=${skipToken}`; + } + return this.getResource(url).then(async (result: any) => { + let list: Array<{ text: string; value: string }> = []; + if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) { + list = ResponseParser.parseResourceNames(result, 'microsoft.storage/storageaccounts'); + for (let i = 0; i < list.length; i++) { + list[i].text += '/default'; + list[i].value += '/default'; + } + } else { + list = ResponseParser.parseResourceNames(result, metricNamespace); + } - return list; + if (result.nextLink) { + // If there is a nextLink, we should request more pages + const nextURL = new URL(result.nextLink); + const nextToken = nextURL.searchParams.get('$skiptoken'); + if (!nextToken) { + throw Error('unable to request the next page of resources'); + } + const nextPage = await this.getResourceNames({ metricNamespace, subscriptionId, resourceGroup }, nextToken); + list = list.concat(nextPage); + } + + return list; + }); }); + return (await Promise.all(promises)).flat(); } getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean) { const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( this.resourcePath, this.apiPreviewVersion, - this.replaceTemplateVariables(query), + // Only use the first query, as the metric namespaces should be the same for all queries + this.replaceSingleTemplateVariables(query), globalRegion, this.templateSrv ); @@ -246,7 +249,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { @@ -259,7 +263,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { @@ -293,16 +298,42 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend 0; } - private replaceTemplateVariables(query: T) { - const templateSrv = getTemplateSrv(); + private replaceSingleTemplateVariables(query: T, scopedVars?: ScopedVars) { + // This method evaluates template variables supporting multiple values but only returns the first value. + // This will work as far as the the first combination of variables is valid. + // For example if 'rg1' contains 'res1' and 'rg2' contains 'res2' then + // { resourceGroup: ['rg1', 'rg2'], resourceName: ['res1', 'res2'] } would return + // { resourceGroup: 'rg1', resourceName: 'res1' } which is valid but + // { resourceGroup: ['rg1', 'rg2'], resourceName: ['res2'] } would result in + // { resourceGroup: 'rg1', resourceName: 'res2' } which is not. + return this.replaceTemplateVariables(query, scopedVars)[0]; + } - const workingQuery: { [K in keyof T]: string } = { ...query }; + private replaceTemplateVariables(query: T, scopedVars?: ScopedVars) { + const workingQueries: Array<{ [K in keyof T]: string }> = [{ ...query }]; const keys = Object.keys(query) as Array; keys.forEach((key) => { - workingQuery[key] = templateSrv.replace(workingQuery[key]); + const replaced = this.templateSrv.replace(workingQueries[0][key], scopedVars, 'raw'); + if (replaced.includes(',')) { + const multiple = replaced.split(','); + const currentQueries = [...workingQueries]; + multiple.forEach((value, i) => { + currentQueries.forEach((q) => { + if (i === 0) { + q[key] = value; + } else { + workingQueries.push({ ...q, [key]: value }); + } + }); + }); + } else { + workingQueries.forEach((q) => { + q[key] = replaced; + }); + } }); - return workingQuery; + return workingQueries; } async getProvider(providerName: string) { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.test.tsx index 319b03cd96e..4aab7246106 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.test.tsx @@ -323,5 +323,21 @@ describe('VariableEditor:', () => { }) ); }); + + it('should run the query if requesting regions', async () => { + const onChange = jest.fn(); + const { rerender } = render(); + // wait for initial load + await waitFor(() => expect(screen.getByText('Logs')).toBeInTheDocument()); + await selectAndRerender('select query type', 'Regions', onChange, rerender); + await selectAndRerender('select subscription', 'Primary Subscription', onChange, rerender); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + queryType: AzureQueryType.LocationsQuery, + subscription: 'sub', + refId: 'A', + }) + ); + }); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.tsx index 3b5feed71d0..44c77300584 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/VariableEditor/VariableEditor.tsx @@ -30,6 +30,7 @@ const VariableEditor = (props: Props) => { { label: 'Subscriptions', value: AzureQueryType.SubscriptionsQuery }, { label: 'Resource Groups', value: AzureQueryType.ResourceGroupsQuery }, { label: 'Namespaces', value: AzureQueryType.NamespacesQuery }, + { label: 'Regions', value: AzureQueryType.LocationsQuery }, { label: 'Resource Names', value: AzureQueryType.ResourceNamesQuery }, { label: 'Metric Names', value: AzureQueryType.MetricNamesQuery }, { label: 'Workspaces', value: AzureQueryType.WorkspacesQuery }, @@ -93,6 +94,9 @@ const VariableEditor = (props: Props) => { setRequireNamespace(true); setRequireResource(true); break; + case AzureQueryType.LocationsQuery: + setRequireSubscription(true); + break; } }, [queryType]); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts index a3903e0bceb..aadb5583421 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts @@ -156,11 +156,7 @@ export default class Datasource extends DataSourceWithBackend = []; + locationMap.forEach((loc) => { + res.push({ text: loc.displayName, value: loc.name }); + }); + return { + data: res?.length ? [toDataFrame(res)] : [], + }; + } default: request.targets[0] = queryObj; const queryResp = await lastValueFrom(this.datasource.query(request)); diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 3956c836a19..edc9f18abb2 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -24,6 +24,7 @@ import { InlineFieldRow, InlineLabel, FileDropzone, + FileDropzoneDefaultChildren, DropzoneFile, Themeable2, withTheme2, @@ -399,7 +400,9 @@ export class UnthemedQueryEditor extends PureComponent { fileListRenderer={this.fileListRenderer} options={{ onDropAccepted: this.onDropAccepted, maxSize: 200000, multiple: false }} onLoad={this.onFileDrop} - > + > + + {file && (
{file?.name} diff --git a/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx index d0fdf8c752b..acdd562496f 100644 --- a/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx @@ -6,7 +6,15 @@ import { onUpdateDatasourceJsonDataOptionSelect, onUpdateDatasourceJsonDataOptionChecked, } from '@grafana/data'; -import { Alert, DataSourceHttpSettings, InlineFormLabel, LegacyForms, Select } from '@grafana/ui'; +import { + Alert, + DataSourceHttpSettings, + InlineFormLabel, + LegacyForms, + Select, + SecureSocksProxySettings, +} from '@grafana/ui'; +import { config } from 'app/core/config'; import store from 'app/core/store'; import { GraphiteOptions, GraphiteType } from '../types'; @@ -75,6 +83,9 @@ export class ConfigEditor extends PureComponent { dataSourceConfig={options} onChange={onOptionsChange} /> + {config.featureToggles.secureSocksDatasourceProxy && ( + + )}

Graphite details

diff --git a/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx b/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx index 7918fc0546b..331f5fed3c2 100644 --- a/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx @@ -11,7 +11,17 @@ import { onUpdateDatasourceSecureJsonDataOption, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { Alert, DataSourceHttpSettings, InfoBox, InlineField, InlineFormLabel, LegacyForms, Select } from '@grafana/ui'; +import { + Alert, + DataSourceHttpSettings, + InfoBox, + InlineField, + InlineFormLabel, + LegacyForms, + Select, + SecureSocksProxySettings, +} from '@grafana/ui'; +import { config } from 'app/core/config'; const { Input, SecretFormField } = LegacyForms; import { BROWSER_MODE_DISABLED_MESSAGE } from '../constants'; @@ -316,6 +326,10 @@ export class ConfigEditor extends PureComponent { onChange={onOptionsChange} /> + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} +

InfluxDB Details

diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 878cb0ced9b..33dabd8cd23 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -403,6 +403,10 @@ export default class InfluxDatasource extends DataSourceWithBackend> => { const builder = new InfluxQueryBuilder(target, datasource.database); const q = builder.buildExploreQuery(type, withKey, withMeasurementFilter); - return datasource.metricFindQuery(q); + const options = { policy: target.policy }; + return datasource.metricFindQuery(q, options); }; export async function getAllPolicies(datasource: InfluxDatasource): Promise { diff --git a/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx b/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx index 2ad3f9865f7..aa2df3773ed 100644 --- a/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { DataSourceHttpSettings } from '@grafana/ui'; -import { SpanBarSettings } from '@jaegertracing/jaeger-ui-components'; +import { DataSourceHttpSettings, SecureSocksProxySettings } from '@grafana/ui'; import { NodeGraphSettings } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsSettings } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; +import { SpanBarSettings } from 'app/features/explore/TraceView/components'; export type Props = DataSourcePluginOptionsEditorProps; @@ -20,6 +20,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { onChange={onOptionsChange} /> + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} +
diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index 85e3ae1badd..f8288806604 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -15,10 +15,10 @@ import { ScopedVars, } from '@grafana/data'; import { BackendSrvRequest, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { SpanBarOptions } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { serializeParams } from 'app/core/utils/fetch'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { SpanBarOptions } from 'app/features/explore/TraceView/components'; import { ALL_OPERATIONS_KEY } from './components/SearchForm'; import { createGraphFrames } from './graphTransform'; diff --git a/public/app/plugins/datasource/jaeger/responseTransform.ts b/public/app/plugins/datasource/jaeger/responseTransform.ts index 07123123422..507e5646cfb 100644 --- a/public/app/plugins/datasource/jaeger/responseTransform.ts +++ b/public/app/plugins/datasource/jaeger/responseTransform.ts @@ -6,7 +6,7 @@ import { TraceLog, TraceSpanRow, } from '@grafana/data'; -import { transformTraceData } from '@jaegertracing/jaeger-ui-components'; +import { transformTraceData } from 'app/features/explore/TraceView/components'; import { JaegerResponse, Span, TraceProcess, TraceResponse } from './types'; diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx new file mode 100644 index 00000000000..1e730abbfcc --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.test.tsx @@ -0,0 +1,116 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; + +import { LogRowModel } from '@grafana/data'; + +import LokiLanguageProvider from '../LanguageProvider'; + +import { LokiContextUi, LokiContextUiProps } from './LokiContextUi'; + +// we have to mock out reportInteraction, otherwise it crashes the test. +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: () => null, +})); + +describe('LokiContextUi', () => { + const setupProps = (): LokiContextUiProps => { + const mockLanguageProvider = { + start: jest.fn().mockImplementation(() => Promise.resolve()), + getLabelValues: (name: string) => { + switch (name) { + case 'label1': + return ['value1-1', 'value1-2']; + case 'label2': + return ['value2-1', 'value2-2']; + case 'label3': + return ['value3-1', 'value3-2']; + } + return []; + }, + fetchSeriesLabels: (selector: string) => { + switch (selector) { + case '{label1="value1-1"}': + return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; + case '{label1=~"value1-1|value1-2"}': + return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; + } + // Allow full set by default + return { + label1: ['value1-1', 'value1-2'], + label2: ['value2-1', 'value2-2'], + }; + }, + getLabelKeys: () => ['label1', 'label2'], + }; + + const defaults: LokiContextUiProps = { + languageProvider: mockLanguageProvider as unknown as LokiLanguageProvider, + updateFilter: jest.fn(), + row: { + entry: 'WARN test 1.23 on [xxx]', + labels: { + label1: 'value1', + label3: 'value3', + }, + } as unknown as LogRowModel, + }; + + return defaults; + }; + + it('renders and shows basic text', async () => { + const props = setupProps(); + render(); + + // Initial set of labels is available and not selected + expect(await screen.findByText(/Select labels to include in the context query/)).toBeInTheDocument(); + }); + + it('starts the languageProvider', async () => { + const props = setupProps(); + render(); + + await waitFor(() => { + expect(props.languageProvider.start).toHaveBeenCalled(); + }); + }); + + it('finds label1 as a real label', async () => { + const props = setupProps(); + render(); + await waitFor(() => { + expect(props.languageProvider.start).toHaveBeenCalled(); + }); + const select = await screen.findAllByRole('combobox'); + await selectOptionInTest(select[0], 'label1'); + }); + + it('finds label3 as a parsed label', async () => { + const props = setupProps(); + render(); + await waitFor(() => { + expect(props.languageProvider.start).toHaveBeenCalled(); + }); + const select = await screen.findAllByRole('combobox'); + await selectOptionInTest(select[1], 'label3'); + }); + + it('calls updateFilter when selecting a label', async () => { + jest.useFakeTimers(); + const props = setupProps(); + render(); + await waitFor(() => { + expect(props.languageProvider.start).toHaveBeenCalled(); + }); + const select = await screen.findAllByRole('combobox'); + await selectOptionInTest(select[1], 'label3'); + act(() => { + jest.runAllTimers(); + }); + expect(props.updateFilter).toHaveBeenCalled(); + + jest.useRealTimers(); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiContextUi.tsx b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx new file mode 100644 index 00000000000..3f14392eccc --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiContextUi.tsx @@ -0,0 +1,178 @@ +import { css } from '@emotion/css'; +import memoizeOne from 'memoize-one'; +import React, { useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; + +import { GrafanaTheme2, LogRowModel, SelectableValue } from '@grafana/data'; +import { MultiSelect, Tag, Tooltip, useStyles2 } from '@grafana/ui'; + +import LokiLanguageProvider from '../LanguageProvider'; +import { ContextFilter } from '../types'; + +export interface LokiContextUiProps { + languageProvider: LokiLanguageProvider; + row: LogRowModel; + updateFilter: (value: ContextFilter[]) => void; +} + +function getStyles(theme: GrafanaTheme2) { + return { + labels: css` + display: flex; + gap: 2px; + `, + multiSelectWrapper: css` + display: flex; + flex-direction: column; + flex: 1; + margin-top: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; + `, + multiSelect: css` + & .scrollbar-view { + overscroll-behavior: contain; + } + `, + }; +} + +const formatOptionLabel = memoizeOne(({ label, description }: SelectableValue) => ( + + {label} + +)); + +export function LokiContextUi(props: LokiContextUiProps) { + const { row, languageProvider, updateFilter } = props; + const styles = useStyles2(getStyles); + + const [contextFilters, setContextFilters] = useState([]); + const [initialized, setInitialized] = useState(false); + const timerHandle = React.useRef(); + const previousInitialized = React.useRef(false); + useEffect(() => { + if (!initialized) { + return; + } + + // don't trigger if we initialized, this will be the same query anyways. + if (!previousInitialized.current) { + previousInitialized.current = initialized; + return; + } + + if (timerHandle.current) { + clearTimeout(timerHandle.current); + } + timerHandle.current = window.setTimeout(() => { + updateFilter(contextFilters); + }, 1500); + + return () => { + clearTimeout(timerHandle.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextFilters, initialized]); + + useAsync(async () => { + await languageProvider.start(); + const allLabels = languageProvider.getLabelKeys(); + const contextFilters: ContextFilter[] = []; + + Object.entries(row.labels).forEach(([label, value]) => { + const filter: ContextFilter = { + label, + value: label, // this looks weird in the first place, but we need to set the label as value here + enabled: allLabels.includes(label), + fromParser: !allLabels.includes(label), + description: value, + }; + contextFilters.push(filter); + }); + + setContextFilters(contextFilters); + setInitialized(true); + }); + + const realLabels = contextFilters.filter(({ fromParser }) => !fromParser); + const realLabelsEnabled = realLabels.filter(({ enabled }) => enabled); + + const parsedLabels = contextFilters.filter(({ fromParser }) => fromParser); + const parsedLabelsEnabled = parsedLabels.filter(({ enabled }) => enabled); + + return ( +
+
+ {' '} + + + {' '} + Select labels to include in the context query: +
+
+ { + return setContextFilters( + contextFilters.map((filter) => { + if (filter.fromParser) { + return filter; + } + filter.enabled = keys.some((key) => key.value === filter.value); + return filter; + }) + ); + }} + /> +
+ {parsedLabels.length > 0 && ( +
+ { + setContextFilters( + contextFilters.map((filter) => { + if (!filter.fromParser) { + return filter; + } + filter.enabled = keys.some((key) => key.value === filter.value); + return filter; + }) + ); + }} + /> +
+ )} +
+ ); +} diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx index 85a9bd97902..deb39ffe250 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx @@ -171,6 +171,7 @@ export const LokiQueryEditor = React.memo((props) => { onRunQuery={onRunQuery} app={app} maxLines={datasource.maxLines} + datasource={datasource} /> diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx index 64403d5328d..73706113841 100644 --- a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { - DataSourcePluginOptionsEditorProps, - DataSourceSettings, - onUpdateDatasourceJsonDataOptionChecked, -} from '@grafana/data'; +import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { AlertingSettings, DataSourceHttpSettings, InlineField, InlineSwitch } from '@grafana/ui'; +import { AlertingSettings, DataSourceHttpSettings, SecureSocksProxySettings } from '@grafana/ui'; import { LokiOptions } from '../types'; @@ -32,7 +28,6 @@ const setDerivedFields = makeJsonUpdater('derivedFields'); export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; - const socksProxy = config.featureToggles.secureSocksDatasourceProxy; return ( <> @@ -43,23 +38,8 @@ export const ConfigEditor = (props: Props) => { onChange={onOptionsChange} /> - {socksProxy && ( - <> -

Secure Socks Proxy

-
-
- - - -
- + {config.featureToggles.secureSocksDatasourceProxy && ( + )} options={options} onOptionsChange={onOptionsChange} /> diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index c902aaca82f..1b19a7acb6d 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -33,7 +33,7 @@ import { CustomVariableModel } from '../../../features/variables/types'; import { LokiDatasource, REF_ID_DATA_SAMPLES } from './datasource'; import { createLokiDatasource, createMetadataRequest } from './mocks'; import { parseToNodeNamesArray } from './queryUtils'; -import { LokiOptions, LokiQuery, LokiQueryType, LokiVariableQueryType } from './types'; +import { LokiOptions, LokiQuery, LokiQueryType, LokiVariableQueryType, SupportingQueryType } from './types'; import { LokiVariableSupport } from './variables'; jest.mock('@grafana/runtime', () => { @@ -894,7 +894,7 @@ describe('LokiDatasource', () => { it('creates provider for logs query', () => { const options = getQueryOptions({ - targets: [{ expr: '{label=value}', refId: 'A' }], + targets: [{ expr: '{label=value}', refId: 'A', queryType: LokiQueryType.Range }], }); expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).toBeDefined(); @@ -911,8 +911,8 @@ describe('LokiDatasource', () => { it('creates provider if at least one query is a logs query', () => { const options = getQueryOptions({ targets: [ - { expr: 'rate({label=value}[1m])', refId: 'A' }, - { expr: '{label=value}', refId: 'B' }, + { expr: 'rate({label=value}[1m])', queryType: LokiQueryType.Range, refId: 'A' }, + { expr: '{label=value}', queryType: LokiQueryType.Range, refId: 'B' }, ], }); @@ -962,6 +962,93 @@ describe('LokiDatasource', () => { }); }); + describe('getSupplementaryQuery', () => { + let ds: LokiDatasource; + beforeEach(() => { + ds = createLokiDatasource(templateSrvStub); + }); + + describe('logs volume', () => { + it('returns logs volume query for range log query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, { + expr: '{label=value}', + queryType: LokiQueryType.Range, + refId: 'A', + }) + ).toEqual({ + expr: 'sum by (level) (count_over_time({label=value}[$__interval]))', + instant: false, + queryType: 'range', + refId: 'log-volume-A', + supportingQueryType: SupportingQueryType.LogsVolume, + }); + }); + + it('does not return logs volume query for instant log query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, { + expr: '{label=value}', + queryType: LokiQueryType.Instant, + refId: 'A', + }) + ).toEqual(undefined); + }); + + it('does not return logs volume query for metric query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, { + expr: 'rate({label=value}[5m]', + queryType: LokiQueryType.Range, + refId: 'A', + }) + ).toEqual(undefined); + }); + }); + + describe('logs sample', () => { + it('returns logs sample query for range metric query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, { + expr: 'rate({label=value}[5m]', + queryType: LokiQueryType.Range, + refId: 'A', + }) + ).toEqual({ + expr: '{label=value}', + queryType: 'range', + refId: 'log-sample-A', + maxLines: 100, + }); + }); + + it('returns logs sample query for instant metric query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, { + expr: 'rate({label=value}[5m]', + queryType: LokiQueryType.Instant, + refId: 'A', + }) + ).toEqual({ + expr: '{label=value}', + queryType: 'instant', + refId: 'log-sample-A', + maxLines: 100, + }); + }); + + it('does not return logs sample query for log query query', () => { + expect( + ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, { + expr: '{label=value}', + queryType: LokiQueryType.Range, + refId: 'A', + }) + ).toEqual(undefined); + }); + }); + }); + describe('importing queries', () => { let ds: LokiDatasource; beforeEach(() => { @@ -1081,3 +1168,57 @@ function makeAnnotationQueryRequest(options = {}): AnnotationQueryRequest { + it('returns expression with 1 label', async () => { + const ds = createLokiDatasource(templateSrvStub); + + const row: LogRowModel = { + rowIndex: 0, + dataFrame: new MutableDataFrame({ + fields: [ + { + name: 'ts', + type: FieldType.time, + values: [0], + }, + ], + }), + labels: { bar: 'baz', foo: 'uniqueParsedLabel' }, + uid: '1', + } as unknown as LogRowModel; + + jest.spyOn(ds.languageProvider, 'start').mockImplementation(() => Promise.resolve([])); + jest.spyOn(ds.languageProvider, 'getLabelKeys').mockImplementation(() => ['foo']); + + const result = await ds.prepareContextExpr(row); + + expect(result).toEqual('{foo="uniqueParsedLabel"}'); + }); + + it('returns empty expression for parsed labels', async () => { + const ds = createLokiDatasource(templateSrvStub); + + const row: LogRowModel = { + rowIndex: 0, + dataFrame: new MutableDataFrame({ + fields: [ + { + name: 'ts', + type: FieldType.time, + values: [0], + }, + ], + }), + labels: { bar: 'baz', foo: 'uniqueParsedLabel' }, + uid: '1', + } as unknown as LogRowModel; + + jest.spyOn(ds.languageProvider, 'start').mockImplementation(() => Promise.resolve([])); + jest.spyOn(ds.languageProvider, 'getLabelKeys').mockImplementation(() => []); + + const result = await ds.prepareContextExpr(row); + + expect(result).toEqual('{}'); + }); +}); diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index a88ef2a63d1..a96fa406940 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -34,7 +34,8 @@ import { TimeRange, toUtc, } from '@grafana/data'; -import { config, DataSourceWithBackend, FetchError } from '@grafana/runtime'; +import { BackendSrvRequest, config, DataSourceWithBackend, FetchError } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { queryLogsSample, queryLogsVolume } from 'app/core/logsModel'; import { convertToWebSocketUrl } from 'app/core/utils/explore'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -50,6 +51,7 @@ import LanguageProvider from './LanguageProvider'; import { LiveStreams, LokiLiveTarget } from './LiveStreams'; import { transformBackendResult } from './backendResultTransformer'; import { LokiAnnotationsQueryEditor } from './components/AnnotationsQueryEditor'; +import { LokiContextUi } from './components/LokiContextUi'; import { escapeLabelValueInExactSelector, escapeLabelValueInSelector, isRegexSelector } from './languageUtils'; import { labelNamesRegex, labelValuesRegex } from './migrations/variableQueryMigrations'; import { @@ -66,17 +68,27 @@ import { getLabelFilterPositions, } from './modifyQuery'; import { getQueryHints } from './queryHints'; -import { getLogQueryFromMetricsQuery, getNormalizedLokiQuery, isLogsQuery, isValidQuery } from './queryUtils'; +import { + getLogQueryFromMetricsQuery, + getNormalizedLokiQuery, + getStreamSelectorsFromQuery, + getParserFromQuery, + isLogsQuery, + isValidQuery, +} from './queryUtils'; import { sortDataFrameByTime } from './sortDataFrame'; import { doLokiChannelStream } from './streaming'; import { trackQuery } from './tracking'; import { + ContextFilter, LokiOptions, LokiQuery, LokiQueryDirection, LokiQueryType, LokiVariableQuery, LokiVariableQueryType, + QueryStats, + SupportingQueryType, } from './types'; import { LokiVariableSupport } from './variables'; @@ -161,62 +173,80 @@ export class LokiDatasource return [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample]; } - getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { - const isQuerySuitable = (query: LokiQuery) => { - const normalized = getNormalizedLokiQuery(query); - const { expr } = normalized; - // it has to be a logs-producing range-query - return expr && isLogsQuery(expr) && normalized.queryType === LokiQueryType.Range; - }; - - const isLogsVolumeAvailable = request.targets.some(isQuerySuitable); - - if (!isLogsVolumeAvailable) { + getSupplementaryQuery(type: SupplementaryQueryType, query: LokiQuery): LokiQuery | undefined { + if (!this.getSupportedSupplementaryQueryTypes().includes(type)) { return undefined; } - const logsVolumeRequest = cloneDeep(request); - logsVolumeRequest.targets = logsVolumeRequest.targets.filter(isQuerySuitable).map((target) => { - const query = removeCommentsFromQuery(target.expr); - return { - ...target, - refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`, - instant: false, - volumeQuery: true, - expr: `sum by (level) (count_over_time(${query}[$__interval]))`, - }; - }); + const normalizedQuery = getNormalizedLokiQuery(query); + const expr = removeCommentsFromQuery(normalizedQuery.expr); + let isQuerySuitable = false; - return queryLogsVolume(this, logsVolumeRequest, { - extractLevel, - range: request.range, - targets: request.targets, - }); + switch (type) { + case SupplementaryQueryType.LogsVolume: + // it has to be a logs-producing range-query + isQuerySuitable = !!(query.expr && isLogsQuery(query.expr) && query.queryType === LokiQueryType.Range); + if (!isQuerySuitable) { + return undefined; + } + + return { + ...normalizedQuery, + refId: `${REF_ID_STARTER_LOG_VOLUME}${normalizedQuery.refId}`, + instant: false, + supportingQueryType: SupportingQueryType.LogsVolume, + expr: `sum by (level) (count_over_time(${expr}[$__interval]))`, + }; + + case SupplementaryQueryType.LogsSample: + // it has to be a metric query + isQuerySuitable = !!(query.expr && !isLogsQuery(query.expr)); + if (!isQuerySuitable) { + return undefined; + } + return { + ...normalizedQuery, + refId: `${REF_ID_STARTER_LOG_SAMPLE}${normalizedQuery.refId}`, + expr: getLogQueryFromMetricsQuery(expr), + maxLines: 100, + }; + + default: + return undefined; + } + } + + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { + const logsVolumeRequest = cloneDeep(request); + const targets = logsVolumeRequest.targets + .map((query) => this.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, query)) + .filter((query): query is LokiQuery => !!query); + + if (!targets.length) { + return undefined; + } + + return queryLogsVolume( + this, + { ...logsVolumeRequest, targets }, + { + extractLevel, + range: request.range, + targets: request.targets, + } + ); } getLogsSampleDataProvider(request: DataQueryRequest): Observable | undefined { - const isQuerySuitable = (query: LokiQuery) => { - return query.expr && !isLogsQuery(query.expr); - }; + const logsSampleRequest = cloneDeep(request); + const targets = logsSampleRequest.targets + .map((query) => this.getSupplementaryQuery(SupplementaryQueryType.LogsSample, query)) + .filter((query): query is LokiQuery => !!query); - const isLogsSampleAvailable = request.targets.some(isQuerySuitable); - - if (!isLogsSampleAvailable) { + if (!targets.length) { return undefined; } - - const logsSampleRequest = cloneDeep(request); - logsSampleRequest.targets = logsSampleRequest.targets.filter(isQuerySuitable).map((target) => { - const query = removeCommentsFromQuery(target.expr); - return { - ...target, - refId: `${REF_ID_STARTER_LOG_SAMPLE}${target.refId}`, - expr: getLogQueryFromMetricsQuery(query), - maxLines: 100, - }; - }); - - return queryLogsSample(this, logsSampleRequest); + return queryLogsSample(this, { ...logsSampleRequest, targets }); } query(request: DataQueryRequest): Observable { @@ -373,15 +403,43 @@ export class LokiDatasource return queries.map((query) => this.languageProvider.exportToAbstractQuery(query)); } - async metadataRequest(url: string, params?: Record) { + async metadataRequest(url: string, params?: Record, options?: Partial) { // url must not start with a `/`, otherwise the AJAX-request // going from the browser will contain `//`, which can cause problems. if (url.startsWith('/')) { throw new Error(`invalid metadata request url: ${url}`); } - const res = await this.getResource(url, params); - return res.data || []; + const res = await this.getResource(url, params, options); + return res.data ?? (res || []); + } + + async getQueryStats(query: LokiQuery): Promise { + const { start, end } = this.getTimeRangeParams(); + const labelMatchers = getStreamSelectorsFromQuery(query.expr); + + let statsForAll: QueryStats = { streams: 0, chunks: 0, bytes: 0, entries: 0 }; + + for (const labelMatcher of labelMatchers) { + try { + const data = await this.metadataRequest( + 'index/stats', + { query: labelMatcher, start, end }, + { showErrorAlert: false } + ); + + statsForAll = { + streams: statsForAll.streams + data.streams, + chunks: statsForAll.chunks + data.chunks, + bytes: statsForAll.bytes + data.bytes, + entries: statsForAll.entries + data.entries, + }; + } catch (e) { + break; + } + } + + return statsForAll; } async metricFindQuery(query: LokiVariableQuery | string) { @@ -576,10 +634,14 @@ export class LokiDatasource return Math.ceil(date.valueOf() * 1e6); } - getLogRowContext = async (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => { + getLogRowContext = async ( + row: LogRowModel, + options?: RowContextOptions, + origQuery?: DataQuery + ): Promise<{ data: DataFrame[] }> => { const direction = (options && options.direction) || 'BACKWARD'; const limit = (options && options.limit) || 10; - const { query, range } = await this.prepareLogRowContextQueryTarget(row, limit, direction); + const { query, range } = await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery); const processDataFrame = (frame: DataFrame): DataFrame => { // log-row-context requires specific field-names to work, so we set them here: "ts", "line", "id" @@ -645,29 +707,17 @@ export class LokiDatasource prepareLogRowContextQueryTarget = async ( row: LogRowModel, limit: number, - direction: 'BACKWARD' | 'FORWARD' + direction: 'BACKWARD' | 'FORWARD', + origQuery?: DataQuery ): Promise<{ query: LokiQuery; range: TimeRange }> => { - // need to await the languageProvider to be started to have all labels. This call is not blocking after it has been called once. - await this.languageProvider.start(); - const labels = this.languageProvider.getLabelKeys(); - const expr = Object.keys(row.labels) - .map((label: string) => { - if (labels.includes(label)) { - // escape backslashes in label as users can't escape them by themselves - return `${label}="${escapeLabelValueInExactSelector(row.labels[label])}"`; - } - return ''; - }) - // Filter empty strings - .filter((label) => !!label) - .join(','); + let expr = await this.prepareContextExpr(row, origQuery); const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer const queryDirection = direction === 'FORWARD' ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; const query: LokiQuery = { - expr: `{${expr}}`, + expr, queryType: LokiQueryType.Range, refId: `${REF_ID_STARTER_LOG_ROW_CONTEXT}${row.dataFrame.refId || ''}`, maxLines: limit, @@ -708,6 +758,71 @@ export class LokiDatasource }; }; + async prepareContextExpr(row: LogRowModel, origQuery?: DataQuery): Promise { + await this.languageProvider.start(); + const labels = this.languageProvider.getLabelKeys(); + const expr = Object.keys(row.labels) + .map((label: string) => { + if (labels.includes(label)) { + // escape backslashes in label as users can't escape them by themselves + return `${label}="${escapeLabelValueInExactSelector(row.labels[label])}"`; + } + return ''; + }) + .filter((label) => !!label) + .join(','); + + return `{${expr}}`; + } + + getLogRowContextUi(row: LogRowModel, runContextQuery: () => void): React.ReactNode { + return LokiContextUi({ + row, + languageProvider: this.languageProvider, + updateFilter: (contextFilters: ContextFilter[]) => { + this.prepareContextExpr = async (row: LogRowModel, origQuery?: DataQuery) => { + await this.languageProvider.start(); + const labels = this.languageProvider.getLabelKeys(); + + let expr = contextFilters + .map((filter) => { + const label = filter.value; + if (filter && !filter.fromParser && filter.enabled && labels.includes(label)) { + // escape backslashes in label as users can't escape them by themselves + return `${label}="${escapeLabelValueInExactSelector(row.labels[label])}"`; + } + return ''; + }) + // Filter empty strings + .filter((label) => !!label) + .join(','); + + expr = `{${expr}}`; + + const parserContextFilters = contextFilters.filter((filter) => filter.fromParser && filter.enabled); + if (parserContextFilters.length) { + // we should also filter for labels from parsers, let's find the right parser + if (origQuery) { + const parser = getParserFromQuery((origQuery as LokiQuery).expr); + if (parser) { + expr = addParserToQuery(expr, parser); + } + } + for (const filter of parserContextFilters) { + if (filter.enabled) { + expr = addLabelToQuery(expr, filter.label, '=', row.labels[filter.label]); + } + } + } + return expr; + }; + if (runContextQuery) { + runContextQuery(); + } + }, + }); + } + testDatasource(): Promise<{ status: string; message: string }> { // Consider only last 10 minutes otherwise request takes too long const nowMs = Date.now(); diff --git a/public/app/plugins/datasource/loki/modifyQuery.ts b/public/app/plugins/datasource/loki/modifyQuery.ts index 610c9b56a22..bc124eed541 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.ts @@ -139,7 +139,7 @@ export function removeCommentsFromQuery(query: string): string { * selector. * @param query */ -function getStreamSelectorPositions(query: string): Position[] { +export function getStreamSelectorPositions(query: string): Position[] { const tree = parser.parse(query); const positions: Position[] = []; tree.iterate({ diff --git a/public/app/plugins/datasource/loki/queryUtils.ts b/public/app/plugins/datasource/loki/queryUtils.ts index a5a1444b86e..779215d1c19 100644 --- a/public/app/plugins/datasource/loki/queryUtils.ts +++ b/public/app/plugins/datasource/loki/queryUtils.ts @@ -21,6 +21,7 @@ import { import { ErrorId } from '../prometheus/querybuilder/shared/parsingUtils'; +import { getStreamSelectorPositions } from './modifyQuery'; import { LokiQuery, LokiQueryType } from './types'; export function formatQuery(selector: string | undefined): string { @@ -284,3 +285,13 @@ export function isQueryWithLineFilter(query: string): boolean { return queryWithLineFilter; } + +export function getStreamSelectorsFromQuery(query: string): string[] { + const labelMatcherPositions = getStreamSelectorPositions(query); + + const labelMatchers = labelMatcherPositions.map((labelMatcher) => { + return query.slice(labelMatcher.from, labelMatcher.to); + }); + + return labelMatchers; +} diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx index 3f0c9e3eb59..ca098f1e3e1 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { createLokiDatasource } from '../../mocks'; import { LokiQuery, LokiQueryType } from '../../types'; import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions'; @@ -47,6 +48,7 @@ function setup(queryOverrides: Partial = {}) { onRunQuery: jest.fn(), onChange: jest.fn(), maxLines: 20, + datasource: createLokiDatasource(), }; const { container } = render(); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx index cc46e9631ce..b9a80ca4df3 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { usePrevious } from 'react-use'; import { CoreApp, SelectableValue } from '@grafana/data'; import { EditorField, EditorRow } from '@grafana/experimental'; @@ -7,8 +8,9 @@ import { RadioButtonGroup, Select, AutoSizeInput } from '@grafana/ui'; import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup'; import { preprocessMaxLines, queryTypeOptions, RESOLUTION_OPTIONS } from '../../components/LokiOptionFields'; +import { LokiDatasource } from '../../datasource'; import { isLogsQuery } from '../../queryUtils'; -import { LokiQuery, LokiQueryType } from '../../types'; +import { LokiQuery, LokiQueryType, QueryStats } from '../../types'; export interface Props { query: LokiQuery; @@ -16,83 +18,112 @@ export interface Props { onRunQuery: () => void; maxLines: number; app?: CoreApp; + datasource: LokiDatasource; } -export const LokiQueryBuilderOptions = React.memo(({ app, query, onChange, onRunQuery, maxLines }) => { - const onQueryTypeChange = (value: LokiQueryType) => { - onChange({ ...query, queryType: value }); - onRunQuery(); - }; +export const LokiQueryBuilderOptions = React.memo( + ({ app, query, onChange, onRunQuery, maxLines, datasource }) => { + const [queryStats, setQueryStats] = useState(); + const prevQuery = usePrevious(query); - const onResolutionChange = (option: SelectableValue) => { - reportInteraction('grafana_loki_resolution_clicked', { - app, - resolution: option.value, - }); - onChange({ ...query, resolution: option.value }); - onRunQuery(); - }; - - const onLegendFormatChanged = (evt: React.FormEvent) => { - onChange({ ...query, legendFormat: evt.currentTarget.value }); - onRunQuery(); - }; - - function onMaxLinesChange(e: React.SyntheticEvent) { - const newMaxLines = preprocessMaxLines(e.currentTarget.value); - if (query.maxLines !== newMaxLines) { - onChange({ ...query, maxLines: newMaxLines }); + const onQueryTypeChange = (value: LokiQueryType) => { + onChange({ ...query, queryType: value }); onRunQuery(); + }; + + const onResolutionChange = (option: SelectableValue) => { + reportInteraction('grafana_loki_resolution_clicked', { + app, + resolution: option.value, + }); + onChange({ ...query, resolution: option.value }); + onRunQuery(); + }; + + const onLegendFormatChanged = (evt: React.FormEvent) => { + onChange({ ...query, legendFormat: evt.currentTarget.value }); + onRunQuery(); + }; + + function onMaxLinesChange(e: React.SyntheticEvent) { + const newMaxLines = preprocessMaxLines(e.currentTarget.value); + if (query.maxLines !== newMaxLines) { + onChange({ ...query, maxLines: newMaxLines }); + onRunQuery(); + } } - } - let queryType = query.queryType ?? (query.instant ? LokiQueryType.Instant : LokiQueryType.Range); - let showMaxLines = isLogsQuery(query.expr); + useEffect(() => { + if (query.expr === prevQuery?.expr) { + return; + } - return ( - - - { + const res = await datasource.getQueryStats(query); + + // this filters out the case where the user has not configured loki to use tsdb, in that case all keys in the query stats will be 0 + Object.values(res).every((v) => v === 0) ? setQueryStats(undefined) : setQueryStats(res); + }; + makeAsyncRequest(); + }, [query, prevQuery, datasource]); + + let queryType = query.queryType ?? (query.instant ? LokiQueryType.Instant : LokiQueryType.Range); + let showMaxLines = isLogsQuery(query.expr); + + return ( + + - - - - - - {showMaxLines && ( - + - )} - - + + + + ); + } +); function getCollapsedInfo( query: LokiQuery, diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index 574d3e1a52f..ec4650b12b7 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -42,8 +42,8 @@ export interface LokiQuery extends DataQuery { legendFormat?: string; maxLines?: number; resolution?: number; - /** Used in range queries */ - volumeQuery?: boolean; + /** Used only to identify supporting queries, e.g. logs volume, logs sample and data sample */ + supportingQueryType?: SupportingQueryType; /* @deprecated now use queryType */ range?: boolean; /* @deprecated now use queryType */ @@ -56,7 +56,6 @@ export interface LokiOptions extends DataSourceJsonData { derivedFields?: DerivedFieldConfig[]; alertmanager?: string; keepCookies?: string[]; - enableSecureSocksProxy?: boolean; } export interface LokiStats { @@ -154,3 +153,24 @@ export interface LokiVariableQuery extends DataQuery { label?: string; stream?: string; } + +export interface QueryStats { + streams: number; + chunks: number; + bytes: number; + entries: number; +} + +export enum SupportingQueryType { + LogsVolume = 'logsVolume', + LogsSample = 'logsSample', + DataSample = 'dataSample', +} + +export interface ContextFilter { + enabled: boolean; + label: string; + value: string; + fromParser: boolean; + description?: string; +} diff --git a/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx index fc0cf8395cf..061a37acbf9 100644 --- a/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { DataSourceHttpSettings } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { DataSourceHttpSettings, SecureSocksProxySettings } from '@grafana/ui'; import { OpenTsdbOptions } from '../types'; @@ -17,6 +18,9 @@ export const ConfigEditor = (props: DataSourcePluginOptionsEditorProps + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} ); diff --git a/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx index f1ca5d5806a..65aa4a669a1 100644 --- a/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx @@ -1,12 +1,8 @@ import React, { useRef } from 'react'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; -import { - DataSourcePluginOptionsEditorProps, - DataSourceSettings, - onUpdateDatasourceJsonDataOptionChecked, -} from '@grafana/data'; -import { AlertingSettings, DataSourceHttpSettings, Alert, InlineField, InlineSwitch } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; +import { AlertingSettings, DataSourceHttpSettings, Alert, SecureSocksProxySettings } from '@grafana/ui'; import { config } from 'app/core/config'; import { PromOptions } from '../types'; @@ -29,8 +25,6 @@ export const ConfigEditor = (props: Props) => { azureSettingsUI: AzureAuthSettings, }; - const socksProxy = config.featureToggles.secureSocksDatasourceProxy; - return ( <> {options.access === 'direct' && ( @@ -49,23 +43,8 @@ export const ConfigEditor = (props: Props) => { renderSigV4Editor={} /> - {socksProxy && ( - <> -

Secure Socks Proxy

-
-
- - - -
- + {config.featureToggles.secureSocksDatasourceProxy && ( + )} options={options} onOptionsChange={onOptionsChange} /> diff --git a/public/app/plugins/datasource/prometheus/datasource.tsx b/public/app/plugins/datasource/prometheus/datasource.tsx index 06e1721fd18..afc3faf44ab 100644 --- a/public/app/plugins/datasource/prometheus/datasource.tsx +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -70,6 +70,8 @@ import { PrometheusVariableSupport } from './variables'; const ANNOTATION_QUERY_STEP_DEFAULT = '60s'; const GET_AND_POST_METADATA_ENDPOINTS = ['api/v1/query', 'api/v1/query_range', 'api/v1/series', 'api/v1/labels']; +export const InstantQueryRefIdIndex = '-Instant'; + export class PrometheusDatasource extends DataSourceWithBackend implements DataSourceWithQueryImportSupport, DataSourceWithQueryExportSupport @@ -430,7 +432,7 @@ export class PrometheusDatasource }, { ...processedTarget, - refId: processedTarget.refId + '-Instant', + refId: processedTarget.refId + InstantQueryRefIdIndex, range: false, } ); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.test.tsx new file mode 100644 index 00000000000..2daf2794646 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { QueryOptionGroup, Props } from './QueryOptionGroup'; + +describe('Query size approximation', () => { + const _1KiB = 1024; // size of 1 KiB in bytes + const _1GiB = 1073741824; // ... + const _1PiB = 1125899906842624; + + it('renders the correct data value given 1 KiB', async () => { + const props = createProps(_1KiB); + render(); + expect(screen.getByText(/This query will process approximately 1.0 KiB/)).toBeInTheDocument(); + }); + + it('renders the correct data value given 1 GiB', async () => { + const props = createProps(_1GiB); + render(); + expect(screen.getByText(/This query will process approximately 1.0 GiB/)).toBeInTheDocument(); + }); + + it('renders the correct data value given 1 PiB', async () => { + const props = createProps(_1PiB); + render(); + expect(screen.getByText(/This query will process approximately 1.0 PiB/)).toBeInTheDocument(); + }); + + it('updates the data value on data change', async () => { + const props1 = createProps(_1KiB); + const props2 = createProps(_1PiB); + + const { rerender } = render(); + expect(screen.getByText(/This query will process approximately 1.0 KiB/)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/This query will process approximately 1.0 PiB/)).toBeInTheDocument(); + }); +}); + +function createProps(bytes?: number): Props { + return { + title: 'Options', + collapsedInfo: ['Type: Range', 'Line limit: 1000'], + children:
, + queryStats: { streams: 0, chunks: 0, bytes: bytes ?? 0, entries: 0 }, + }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.tsx index d3dc219ecd8..9e093588bf0 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup.tsx @@ -2,42 +2,58 @@ import { css } from '@emotion/css'; import React from 'react'; import { useToggle } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; +import { getValueFormat, GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Icon, useStyles2 } from '@grafana/ui'; +import { QueryStats } from 'app/plugins/datasource/loki/types'; export interface Props { title: string; collapsedInfo: string[]; + queryStats?: QueryStats; children: React.ReactNode; } -export function QueryOptionGroup({ title, children, collapsedInfo }: Props) { +export function QueryOptionGroup({ title, children, collapsedInfo, queryStats }: Props) { const [isOpen, toggleOpen] = useToggle(false); const styles = useStyles2(getStyles); + const convertUnits = (): string => { + const { text, suffix } = getValueFormat('bytes')(queryStats!.bytes, 1); + return text + suffix; + }; + return ( - -
-
- -
-
{title}
- {!isOpen && ( -
- {collapsedInfo.map((x, i) => ( - {x} - ))} +
+ +
+
+
- )} -
- {isOpen &&
{children}
} -
+
{title}
+ {!isOpen && ( +
+ {collapsedInfo.map((x, i) => ( + {x} + ))} +
+ )} +
+ {isOpen &&
{children}
} + + {queryStats &&

This query will process approximately {convertUnits()}.

} +
); } const getStyles = (theme: GrafanaTheme2) => { return { + wrapper: css({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }), switchLabel: css({ color: theme.colors.text.secondary, cursor: 'pointer', @@ -79,5 +95,10 @@ const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.secondary, marginRight: `${theme.spacing(1)}`, }), + stats: css({ + margin: '0px', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), }; }; diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 3fbede5a34c..62aec75433b 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -34,7 +34,6 @@ export interface PromOptions extends DataSourceJsonData { exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; prometheusType?: PromApplication; prometheusVersion?: string; - enableSecureSocksProxy?: boolean; defaultEditor?: QueryEditorMode; } diff --git a/public/app/plugins/datasource/tempo/CheatSheet.tsx b/public/app/plugins/datasource/tempo/CheatSheet.tsx index 2095a13d4a1..40afaf9cfff 100644 --- a/public/app/plugins/datasource/tempo/CheatSheet.tsx +++ b/public/app/plugins/datasource/tempo/CheatSheet.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; export default function CheatSheet() { reportInteraction('grafana_traces_cheatsheet_clicked', { datasourceType: 'tempo', + grafana_version: config.buildInfo.version, }); return ( diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx index 65ff13695d5..11e859853e7 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx @@ -7,7 +7,7 @@ import { TempoQuery } from '../types'; import NativeSearch from './NativeSearch'; -const getOptions = jest.fn().mockImplementation(() => { +const getOptionsV1 = jest.fn().mockImplementation(() => { return new Promise((resolve) => { setTimeout(() => { resolve([ @@ -26,7 +26,7 @@ const getOptions = jest.fn().mockImplementation(() => { jest.mock('../language_provider', () => { return jest.fn().mockImplementation(() => { - return { getOptions }; + return { getOptionsV1 }; }); }); diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx index eb46bc551e5..7ea74ab8f4a 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx @@ -45,7 +45,7 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props setIsLoading((prevValue) => ({ ...prevValue, [name]: true })); try { - const options = await languageProvider.getOptions(lpName); + const options = await languageProvider.getOptionsV1(lpName); const filteredOptions = options.filter((item) => (item.value ? fuzzyMatch(item.value, query).found : false)); return filteredOptions; } catch (error) { diff --git a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx index 1648378760e..5597d05e5a5 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx @@ -3,7 +3,7 @@ import React from 'react'; import useAsync from 'react-use/lib/useAsync'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { FileDropzone, InlineField, @@ -107,6 +107,7 @@ class TempoQueryFieldComponent extends React.PureComponent { reportInteraction('grafana_traces_query_type_changed', { datasourceType: 'tempo', app: app ?? '', + grafana_version: config.buildInfo.version, newQueryType: v, previousQueryType: query.queryType ?? '', }); diff --git a/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts index a8ac9d088e3..93f51e9ad29 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts @@ -78,7 +78,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP if (this.cachedValues.hasOwnProperty(tagName)) { tagValues = this.cachedValues[tagName]; } else { - tagValues = await this.languageProvider.getOptions(tagName); + tagValues = await this.languageProvider.getOptionsV1(tagName); this.cachedValues[tagName] = tagValues; } return tagValues; diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 32f633a5414..7f792be4527 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { DataSourceHttpSettings } from '@grafana/ui'; -import { SpanBarSettings } from '@jaegertracing/jaeger-ui-components'; +import { DataSourceHttpSettings, SecureSocksProxySettings } from '@grafana/ui'; import { NodeGraphSettings } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsSettings } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; +import { SpanBarSettings } from 'app/features/explore/TraceView/components'; import { LokiSearchSettings } from './LokiSearchSettings'; import { QuerySettings } from './QuerySettings'; @@ -25,6 +25,10 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { onChange={onOptionsChange} /> + {config.featureToggles.secureSocksDatasourceProxy && ( + + )} +
diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 4bad7bb23ec..3e8c2111aa6 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -24,10 +24,10 @@ import { TemplateSrv, getTemplateSrv, } from '@grafana/runtime'; -import { SpanBarOptions } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { serializeParams } from 'app/core/utils/fetch'; +import { SpanBarOptions } from 'app/features/explore/TraceView/components'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { LokiOptions } from '../loki/types'; @@ -108,7 +108,9 @@ export class TempoDatasource extends DataSourceWithBackend, datasourceUid: s reportInteraction('grafana_traces_service_graph_size', { datasourceType: 'tempo', + grafana_version: config.buildInfo.version, nodeLength, edgeLength, }); diff --git a/public/app/plugins/datasource/tempo/language_provider.ts b/public/app/plugins/datasource/tempo/language_provider.ts index 117118e6e4f..d0186053e77 100644 --- a/public/app/plugins/datasource/tempo/language_provider.ts +++ b/public/app/plugins/datasource/tempo/language_provider.ts @@ -87,10 +87,21 @@ export default class TempoLanguageProvider extends LanguageProvider { return { suggestions }; } - async getOptions(tag: string): Promise>> { + async getOptionsV1(tag: string): Promise>> { + const response = await this.request(`/api/search/tag/${tag}/values`); + let options: Array> = []; + if (response && response.tagValues) { + options = response.tagValues.map((v: string) => ({ + value: v, + label: v, + })); + } + return options; + } + + async getOptionsV2(tag: string): Promise>> { const response = await this.request(`/api/v2/search/tag/${tag}/values`); let options: Array> = []; - if (response && response.tagValues) { options = response.tagValues.map((v: { type: string; value: string }) => ({ type: v.type, @@ -98,7 +109,6 @@ export default class TempoLanguageProvider extends LanguageProvider { label: v.value, })); } - return options; } } diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index c043c807740..403787af5fb 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -32,7 +32,7 @@ export function createTableFrame( type: FieldType.time, config: { custom: { - width: 150, + width: 200, }, }, }, @@ -41,7 +41,7 @@ export function createTableFrame( type: FieldType.string, config: { displayNameFromDS: 'Trace ID', - custom: { width: 300 }, + custom: { width: 180 }, links: [ { title: 'Click to open trace ${__value.raw}', diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts index 516aed301de..1533b21cb76 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts @@ -29,7 +29,7 @@ describe('CompletionProvider', () => { it('does not wrap the tag value in quotes if the type in the response is something other than "string"', async () => { const { provider, model } = setup('{foo=}', 5, defaultTags); - jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation( + jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => new Promise((resolve) => { resolve([ @@ -54,7 +54,7 @@ describe('CompletionProvider', () => { it('wraps the tag value in quotes if the type in the response is set to "string"', async () => { const { provider, model } = setup('{foo=}', 5, defaultTags); - jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation( + jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => new Promise((resolve) => { resolve([ @@ -79,7 +79,7 @@ describe('CompletionProvider', () => { it('inserts the tag value without quotes if the user has entered quotes', async () => { const { provider, model } = setup('{foo="}', 6, defaultTags); - jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation( + jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => new Promise((resolve) => { resolve([ @@ -171,7 +171,7 @@ describe('CompletionProvider', () => { it('suggests tag values after a space inside a string', async () => { const { provider, model } = setup('{foo="bar test " }', 15, defaultTags); - jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation( + jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation( () => new Promise((resolve) => { resolve([ diff --git a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts index 3ba40c6b7cd..dc0eab1c857 100644 --- a/public/app/plugins/datasource/tempo/traceql/autocomplete.ts +++ b/public/app/plugins/datasource/tempo/traceql/autocomplete.ts @@ -92,22 +92,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP this.registerInteractionCommandId = id; } - private overrideTagName(tagName: string): string { - switch (tagName) { - case 'status': - return 'status.code'; - default: - return tagName; - } - } - private async getTagValues(tagName: string): Promise>> { let tagValues: Array>; if (this.cachedValues.hasOwnProperty(tagName)) { tagValues = this.cachedValues[tagName]; } else { - tagValues = await this.languageProvider.getOptions(tagName); + tagValues = await this.languageProvider.getOptionsV2(tagName); this.cachedValues[tagName] = tagValues; } return tagValues; @@ -148,8 +139,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP type: 'OPERATOR', })); case 'SPANSET_IN_VALUE': - const tagName = this.overrideTagName(situation.tagName); - const tagValues = await this.getTagValues(tagName); + const tagValues = await this.getTagValues(situation.tagName); const items: Completion[] = []; const getInsertionText = (val: SelectableValue): string => { diff --git a/public/app/plugins/datasource/testdata/QueryEditor.test.tsx b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx index 4713d9752b2..c4eeee9a3ad 100644 --- a/public/app/plugins/datasource/testdata/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { QueryEditor, Props } from './QueryEditor'; import { scenarios } from './__mocks__/scenarios'; import { defaultQuery } from './constants'; +import { TestDataQueryType } from './dataquery.gen'; import { defaultStreamQuery } from './runStreams'; beforeEach(() => { @@ -45,11 +46,13 @@ describe('Test Datasource Query Editor', () => { expect(scs).toHaveLength(scenarios.length); await userEvent.click(screen.getByText('CSV Metric Values')); - expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ scenarioId: 'csv_metric_values' })); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ scenarioId: TestDataQueryType.CSVMetricValues }) + ); await rerender( ); expect(await screen.findByRole('textbox', { name: /string input/i })).toBeInTheDocument(); @@ -61,7 +64,10 @@ describe('Test Datasource Query Editor', () => { expect.objectContaining({ scenarioId: 'grafana_api', stringInput: 'datasources' }) ); rerender( - + ); expect(await screen.findByText('Grafana API')).toBeInTheDocument(); expect(screen.getByText('Data Sources')).toBeInTheDocument(); @@ -72,7 +78,7 @@ describe('Test Datasource Query Editor', () => { expect.objectContaining({ scenarioId: 'streaming_client', stream: defaultStreamQuery }) ); - const streamQuery = { ...defaultQuery, stream: defaultStreamQuery, scenarioId: 'streaming_client' }; + const streamQuery = { ...defaultQuery, stream: defaultStreamQuery, scenarioId: TestDataQueryType.StreamingClient }; rerender(); diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx index b3c7d996b97..699fcd2da1d 100644 --- a/public/app/plugins/datasource/testdata/QueryEditor.tsx +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -17,9 +17,9 @@ import { RawFrameEditor } from './components/RawFrameEditor'; import { SimulationQueryEditor } from './components/SimulationQueryEditor'; import { USAQueryEditor, usaQueryModes } from './components/USAQueryEditor'; import { defaultCSVWaveQuery, defaultPulseQuery, defaultQuery } from './constants'; +import { CSVWave, NodesQuery, TestData, TestDataQueryType, USAQuery } from './dataquery.gen'; import { TestDataDataSource } from './datasource'; import { defaultStreamQuery } from './runStreams'; -import { CSVWave, NodesQuery, TestDataQuery, USAQuery } from './types'; const showLabelsFor = ['random_walk', 'predictable_pulse']; const endpoints = [ @@ -32,26 +32,26 @@ const selectors = editorSelectors.components.DataSource.TestData.QueryTab; export interface EditorProps { onChange: (value: any) => void; - query: TestDataQuery; + query: TestData; ds: TestDataDataSource; } -export type Props = QueryEditorProps; +export type Props = QueryEditorProps; export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) => { query = { ...defaultQuery, ...query }; const { loading, value: scenarioList } = useAsync(async () => { // migrate manual_entry (unusable since 7, removed in 8) - if (query.scenarioId === 'manual_entry' && (query as any).points) { + if (query.scenarioId === TestDataQueryType.ManualEntry && query.points) { let csvContent = 'Time,Value\n'; - for (const point of (query as any).points) { + for (const point of query.points) { csvContent += `${point[1]},${point[0]}\n`; } onChange({ refId: query.refId, datasource: query.datasource, - scenarioId: 'csv_content', + scenarioId: TestDataQueryType.CSVContent, csvContent, }); } @@ -64,7 +64,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) })); }, []); - const onUpdate = (query: TestDataQuery) => { + const onUpdate = (query: TestData) => { onChange(query); onRunQuery(); }; @@ -84,8 +84,8 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) } // Clear model from existing props that belong to other scenarios - const update: TestDataQuery = { - scenarioId: item.value!, + const update: TestData = { + scenarioId: item.value! as TestDataQueryType, refId: query.refId, alias: query.alias, datasource: query.datasource, @@ -96,25 +96,25 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) } switch (scenario.id) { - case 'grafana_api': + case TestDataQueryType.GrafanaAPI: update.stringInput = 'datasources'; break; - case 'streaming_client': + case TestDataQueryType.StreamingClient: update.stream = defaultStreamQuery; break; - case 'live': + case TestDataQueryType.Live: update.channel = 'random-2s-stream'; // default stream break; - case 'simulation': + case TestDataQueryType.Simulation: update.sim = { key: { type: 'flight', tick: 10 } }; // default stream break; - case 'predictable_pulse': + case TestDataQueryType.PredictablePulse: update.pulseWave = defaultPulseQuery; break; - case 'predictable_csv_wave': + case TestDataQueryType.PredictableCSVWave: update.csvWave = defaultCSVWaveQuery; break; - case 'usa': + case TestDataQueryType.USA: update.usa = { mode: usaQueryModes[0].value, }; @@ -243,16 +243,24 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) )} - {scenarioId === 'random_walk' && } - {scenarioId === 'streaming_client' && ( + {scenarioId === TestDataQueryType.RandomWalk && ( + + )} + {scenarioId === TestDataQueryType.StreamingClient && ( )} - {scenarioId === 'live' && } - {scenarioId === 'simulation' && } - {scenarioId === 'raw_frame' && } - {scenarioId === 'csv_file' && } - {scenarioId === 'csv_content' && } - {scenarioId === 'logs' && ( + {scenarioId === TestDataQueryType.Live && } + {scenarioId === TestDataQueryType.Simulation && ( + + )} + {scenarioId === TestDataQueryType.RawFrame && ( + + )} + {scenarioId === TestDataQueryType.CSVFile && } + {scenarioId === TestDataQueryType.CSVContent && ( + + )} + {scenarioId === TestDataQueryType.Logs && ( )} - {scenarioId === 'usa' && } - {scenarioId === 'grafana_api' && ( + {scenarioId === TestDataQueryType.USA && } + {scenarioId === TestDataQueryType.GrafanaAPI && (