From 89e7e25b6059d099f656c55538d8d6e1ca5e122b Mon Sep 17 00:00:00 2001 From: Cedric Ziel Date: Tue, 19 Nov 2024 09:01:58 +0100 Subject: [PATCH 01/60] Backend: Inject server-timing header to match initial loads with client-side telemetry (#94978) feat: inject server-timing header to match initial loads with client-side telemetry Faro Web SDK can read the server-timing info and correlate the initial request with the client side telemetry gather from navigation timings. --- pkg/infra/tracing/tracing.go | 9 +++++++++ pkg/middleware/request_tracing.go | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index 4c6bd6545a3..5f028c6ce80 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -381,6 +381,15 @@ func TraceIDFromContext(ctx context.Context, requireSampled bool) string { return spanCtx.TraceID().String() } +func ServerTimingForSpan(span trace.Span) string { + spanCtx := span.SpanContext() + if !spanCtx.HasTraceID() || !spanCtx.IsValid() { + return "" + } + + return fmt.Sprintf("00-%s-%s-01", spanCtx.TraceID().String(), spanCtx.SpanID().String()) +} + // Error sets the status to error and record the error as an exception in the provided span. func Error(span trace.Span, err error) error { attr := []attribute.KeyValue{} diff --git a/pkg/middleware/request_tracing.go b/pkg/middleware/request_tracing.go index 2985fe0ad60..2f492a697f9 100644 --- a/pkg/middleware/request_tracing.go +++ b/pkg/middleware/request_tracing.go @@ -94,6 +94,14 @@ func RequestTracing(tracer tracing.Tracer) web.Middleware { ), trace.WithSpanKind(trace.SpanKindServer)) defer span.End() + // inject local root span context into the response via server-timing header + // we're doing it this early so that we can capture the root span context + // which is not available later-on. + serverTimingValue := tracing.ServerTimingForSpan(span) + if serverTimingValue != "" { + w.Header().Set("server-timing", fmt.Sprintf("traceparent;desc=\"%s\"", serverTimingValue)) + } + req = req.WithContext(ctx) // Ensure the response writer's status can be captured. From bd56b47bb5c9ac1e26726f478d42534d466629b1 Mon Sep 17 00:00:00 2001 From: Sriram <153843+yesoreyeram@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:47:46 +0000 Subject: [PATCH 02/60] Datasources: Health check error tracking (#96629) * datasource health check error tracking * type fix --- .../src/utils/DataSourceWithBackend.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 04d7293bab8..2ce4b88e85f 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -19,6 +19,7 @@ import { AdHocVariableFilter, } from '@grafana/data'; +import { reportInteraction } from '../analytics/utils'; import { config } from '../config'; import { BackendSrvRequest, @@ -355,8 +356,17 @@ class DataSourceWithBackend< headers: this.getRequestHeaders(), }) ) - .then((v: FetchResponse) => v.data) - .catch((err) => err.data); + .then((v: FetchResponse) => v.data) + .catch((err) => { + let properties: Record = { + plugin_id: this.meta?.id || '', + plugin_version: this.meta?.info?.version || '', + datasource_healthcheck_status: err?.data?.status || 'error', + datasource_healthcheck_message: err?.data?.message || '', + }; + reportInteraction('datasource_health_check_completed', properties); + return err.data; + }); } /** From d3badb8a07a775b585a643eeb6c98de9e5093871 Mon Sep 17 00:00:00 2001 From: antonio <45235678+tonypowa@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:28:27 +0100 Subject: [PATCH 03/60] Alerting-loki tutorial: update docker env (#96486) --- docs/sources/tutorials/create-alerts-with-logs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/tutorials/create-alerts-with-logs/index.md b/docs/sources/tutorials/create-alerts-with-logs/index.md index 1e1de3f9646..39de7c380c2 100644 --- a/docs/sources/tutorials/create-alerts-with-logs/index.md +++ b/docs/sources/tutorials/create-alerts-with-logs/index.md @@ -82,7 +82,7 @@ To demonstrate the observation of data using the Grafana stack, download and run 1. Download and save a Docker compose file to run Grafana, Loki and Promtail. ```bash - wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml + wget https://raw.githubusercontent.com/grafana/loki/refs/heads/main/production/docker-compose.yaml -O docker-compose.yaml ``` 2. Run the Grafana stack. @@ -234,7 +234,7 @@ In this section, we use the default options for Grafana-managed alert rule creat It should return alert instances from log lines with a status code that is not 200 (OK), and that has met the alert condition. The condition for the alert rule to fire is any occurrence that goes over the threshold of `0`. Since the Loki query has returned more than zero alert instances, the alert rule is `Firing`. - {{< figure src="/media/docs/alerting/expression-loki-alert.png" max-width="1200px" caption="Preview of a firing alert instances" >}} + {{< figure src="/media/docs/alerting/firing-loki-alert-rule.png" max-width="1200px" caption="Preview of a firing alert instances" >}} ### Set evaluation behavior From 3fa8df6b6244bb96f44b63467bc8161e0015871c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Tue, 19 Nov 2024 10:46:24 +0100 Subject: [PATCH 04/60] MySQL: Fix tls skip verify option (#96475) MySQL: Fix skip tls verify option --- pkg/tsdb/mysql/mysql.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 6b08adb8491..42b8fb5fe7c 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -123,6 +123,8 @@ func NewInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return nil, err } cnnstr += "&tls=" + tlsConfigString + } else if tlsConfig.InsecureSkipVerify { + cnnstr += "&tls=skip-verify" } if dsInfo.JsonData.Timezone != "" { From 8b7100a9aac591b5c1b1de5b70e3eb6b22c77143 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:29:58 +0100 Subject: [PATCH 05/60] Auth: Add None role to Viewer case (#96624) Add None role to Viewer case --- pkg/services/apiserver/auth/authorizer/org_role.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/services/apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/org_role.go index c79be633df6..017fb0c7b9b 100644 --- a/pkg/services/apiserver/auth/authorizer/org_role.go +++ b/pkg/services/apiserver/auth/authorizer/org_role.go @@ -37,15 +37,13 @@ func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } - case org.RoleViewer: + case org.RoleViewer, org.RoleNone: switch a.GetVerb() { case "get", "list", "watch": return authorizer.DecisionAllow, "", nil default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } - case org.RoleNone: - return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } return authorizer.DecisionDeny, "", nil } From 0929feba06f829351368d3ad4aed2938f2b8c049 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 19 Nov 2024 11:41:31 +0100 Subject: [PATCH 06/60] Alerting: Use NestedFolderPicker (#96398) Co-authored-by: Tom Ratcliffe --- .betterer.results | 3 - .../alerting/unified/CloneRuleEditor.test.tsx | 47 +----------- .../RuleEditorCloudOnlyAllowed.test.tsx | 10 +-- .../unified/RuleEditorCloudRules.test.tsx | 10 +-- .../unified/RuleEditorGrafanaRules.test.tsx | 69 +++-------------- .../PanelAlertTabContent.test.tsx.snap | 1 + .../alerting/unified/api/alertRuleApi.ts | 2 +- .../components/rule-editor/FolderAndGroup.tsx | 53 ++++++------- .../rule-editor/RuleFolderPicker.tsx | 76 ------------------- .../SimplifiedRuleEditor.test.tsx | 19 ++--- .../NotificationPreview.test.tsx | 2 +- .../NotificationPreview.tsx | 4 +- .../unified/mocks/server/handlers/folders.ts | 41 +++++++++- .../alerting/unified/types/rule-form.ts | 6 +- .../alerting/unified/utils/rule-form.ts | 36 +++++---- .../api/browseDashboardsAPI.ts | 4 - .../components/CreateNewButton.tsx | 20 ++++- public/app/types/acl.ts | 1 - 18 files changed, 133 insertions(+), 271 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx diff --git a/.betterer.results b/.betterer.results index 2af5d4872d0..6f16c0ac58a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1682,9 +1682,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 38155aa917a..a34dde92bc8 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -3,9 +3,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils'; import { byRole, byTestId, byText } from 'testing-library-selector'; -import { selectors } from '@grafana/e2e-selectors/src'; import { setDataSourceSrv } from '@grafana/runtime'; -import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { AccessControlAction } from '../../../types'; @@ -18,7 +16,7 @@ import { import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { mockFeatureDiscoveryApi, mockSearchApi, setupMswServer } from './mockApi'; +import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi'; import { grantUserPermissions, mockDataSource, @@ -44,12 +42,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); -// For simplicity of the test we mock the NotificationPreview component -// Otherwise we would need to mock a few more HTTP api calls which are not relevant for these tests -jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () => ({ - NotificationPreview: () =>
, -})); - jest.spyOn(AlertingQueryRunner.prototype, 'run').mockImplementation(() => Promise.resolve()); const server = setupMswServer(); @@ -58,7 +50,7 @@ const ui = { inputs: { name: byRole('textbox', { name: 'name' }), expr: byTestId('expr'), - folderContainer: byTestId(selectors.components.FolderPicker.containerV2), + folderContainer: byTestId('folder-picker'), namespace: byTestId('namespace-picker'), group: byTestId('group-picker'), annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), @@ -84,14 +76,6 @@ describe('CloneRuleEditor', function () { it('should populate form values from the existing alert rule', async function () { setDataSourceSrv(new MockDataSourceSrv({})); - mockSearchApi(server).search([ - mockDashboardSearchItem({ - title: 'folder-one', - uid: grafanaRulerRule.grafana_alert.namespace_uid, - type: DashboardSearchItemType.DashDB, - }), - ]); - render( , { wrapper: Wrapper } @@ -105,7 +89,7 @@ describe('CloneRuleEditor', function () { await waitFor(() => { expect(ui.inputs.name.get()).toHaveValue(`${grafanaRulerRule.grafana_alert.title} (copy)`); }); - expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one'); + expect(ui.inputs.folderContainer.get()).toHaveTextContent('Folder A'); expect(ui.inputs.group.get()).toHaveTextContent(grafanaRulerRule.grafana_alert.rule_group); expect( byRole('listitem', { @@ -147,16 +131,6 @@ describe('CloneRuleEditor', function () { rules: [originRule], }); - mockSearchApi(server).search([ - mockDashboardSearchItem({ - title: 'folder-one', - uid: '123', - type: DashboardSearchItemType.DashDB, - folderTitle: 'folder-one', - folderUid: '123', - }), - ]); - render( ) { - return { - title: '', - uid: '', - type: DashboardSearchItemType.DashDB, - url: '', - uri: '', - items: [], - tags: [], - slug: '', - isStarred: false, - ...searchItem, - }; -} diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index cfc05cee73d..e99c56856d2 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -1,13 +1,11 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; -import { screen, waitForElementToBeRemoved } from 'test/test-utils'; +import { screen } from 'test/test-utils'; import { byText } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; -import { searchFolders } from '../../manage-dashboards/state/actions'; - import { discoverFeaturesByUid } from './api/buildInfo'; import { fetchRulerRulesGroup } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; @@ -30,7 +28,6 @@ jest.mock('./api/ruler', () => ({ fetchRulerRulesGroup: jest.fn(), fetchRulerRulesNamespace: jest.fn(), })); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); // there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // lets just skip it @@ -116,8 +113,6 @@ const dataSources = { setupDataSources(...Object.values(dataSources)); const mocks = { - // getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), api: { discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), @@ -181,11 +176,8 @@ describe('RuleEditor cloud: checking editable data sources', () => { return null; }); - mocks.searchFolders.mockResolvedValue([]); - // render rule editor, select mimir/loki managed alerts const { user } = renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); await ui.inputs.name.find(); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index 6465ba48b86..a3398383671 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -1,7 +1,6 @@ -import userEvent from '@testing-library/user-event'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { screen, waitForElementToBeRemoved } from 'test/test-utils'; +import { screen } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; import { AccessControlAction } from 'app/types'; @@ -21,8 +20,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); - jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); @@ -46,10 +43,7 @@ describe('RuleEditor cloud', () => { }); it('can create a new cloud alert', async () => { - const user = userEvent.setup(); - - renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); + const { user } = renderRuleEditor(); const removeExpressionsButtons = screen.getAllByLabelText('Remove expression'); expect(removeExpressionsButtons).toHaveLength(2); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index d290b8f58e3..4f7776594fd 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -1,51 +1,24 @@ -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { screen } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; -import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; - -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { grantUserPermissions, mockDataSource } from './mocks'; -import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; +import { grafanaRulerGroup } from './mocks/grafanaRulerApi'; import { setupDataSources } from './testSetup/datasources'; import { buildInfoResponse } from './testSetup/featureDiscovery'; -import * as config from './utils/config'; - -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( - onChange(e.target.value)} /> - ), -})); - -jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it -jest.mock('app/features/query/components/QueryEditorRow', () => ({ - QueryEditorRow: () =>

hi

, -})); - -jest.spyOn(config, 'getAllDataSources'); - jest.setTimeout(60 * 1000); -const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), -}; - const server = setupMswServer(); describe('RuleEditor grafana managed rules', () => { @@ -83,41 +56,17 @@ describe('RuleEditor grafana managed rules', () => { setupDataSources(dataSources.default); mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.searchFolders.mockResolvedValue([ - { - title: 'Folder A', - uid: grafanaRulerRule.grafana_alert.namespace_uid, - id: 1, - type: DashboardSearchItemType.DashDB, - }, - { - title: 'Folder B', - id: 2, - uid: 'b', - type: DashboardSearchItemType.DashDB, - }, - { - title: 'Folder / with slash', - uid: 'c', - id: 2, - type: DashboardSearchItemType.DashDB, - }, - ] as DashboardSearchHit[]); + const { user } = renderRuleEditor(); - renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); - - await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); - - const folderInput = await ui.inputs.folder.find(); - await clickSelectOption(folderInput, 'Folder A'); + await user.type(await ui.inputs.name.find(), 'my great new rule'); + await user.click(await screen.findByRole('button', { name: /select folder/i })); + await user.click(await screen.findByLabelText(/folder a/i)); const groupInput = await ui.inputs.group.find(); - await userEvent.click(await byRole('combobox').find(groupInput)); + await user.click(await byRole('combobox').find(groupInput)); await clickSelectOption(groupInput, grafanaRulerGroup.name); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + await user.type(ui.inputs.annotationValue(1).get(), 'some description'); - await userEvent.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.saveAndExit.get()); expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); }); diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index bb0d4a46ec3..4c1b0bb8b65 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -14,6 +14,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button ], "condition": "C", "folder": { + "kind": "folder", "title": "super folder", "uid": "abc", }, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 84d6d15bad9..ef657ef857a 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -17,7 +17,7 @@ import { } from 'app/types/unified-alerting-dto'; import { ExportFormats } from '../components/export/providers'; -import { Folder } from '../components/rule-editor/RuleFolderPicker'; +import { Folder } from '../types/rule-form'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource'; import { arrayKeyValuesToObject } from '../utils/labels'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index 165817ccf3e..aec8020e993 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -4,26 +4,26 @@ import * as React from 'react'; import { useCallback, useMemo, useState } from 'react'; import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form'; -import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { AsyncSelect, Box, Button, Field, Input, Label, Modal, Stack, Text, useStyles2 } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; -import { createFolder } from 'app/features/manage-dashboards/state/actions'; +import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { AccessControlAction } from 'app/types'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; -import { RuleFormValues } from '../../types/rule-form'; +import { Folder, RuleFormValues } from '../../types/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { isGrafanaRecordingRuleByType, isGrafanaRulerRule } from '../../utils/rules'; import { ProvisioningBadge } from '../Provisioning'; import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick'; -import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker'; export const MAX_GROUP_RESULTS = 1000; @@ -164,13 +164,18 @@ export function FolderAndGroup({ (
- { - field.onChange({ title, uid }); + value={folder?.uid} + onChange={(uid, title) => { + if (uid && title) { + setValue('folder', { title, uid }); + } else { + setValue('folder', undefined); + } + resetGroup(); }} /> @@ -288,31 +293,27 @@ function FolderCreationModal({ }): React.ReactElement { const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); const [title, setTitle] = useState(''); + const [createFolder] = useNewFolderMutation(); + const onSubmit = async () => { - const newFolder = await createFolder({ title: title }); - if (!newFolder.uid) { - appEvents.emit(AppEvents.alertError, ['Folder could not be created']); - return; + const { data, error } = await createFolder({ title }); + + if (error) { + notifyApp.error('Failed to create folder'); + } else if (data) { + onCreate({ title: data.title, uid: data.uid }); + notifyApp.success('Folder created'); } - - const folder: Folder = { title: newFolder.title, uid: newFolder.uid }; - onCreate(folder); - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); }; - const error = containsSlashes(title); - return (
Create a new folder to store your rule
- Folder name} - error={"The folder name can't contain slashes"} - invalid={error} - > + Folder name}> + {shouldShowNewSchemaV2Button && ( + + )} )} - {isViewingJSON && ( <> @@ -161,7 +201,12 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) } if (dashboardJson.loading) { - return
Loading...
; + return ( +
+ {' '} + Loading... +
+ ); } return null; @@ -186,6 +231,52 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) )} + {isViewingJSONSchemaV2 && ( + <> + + {({ width }) => { + if (dashboardJsonSchemaV2.value) { + return ( + + ); + } + + if (dashboardJsonSchemaV2.loading) { + return ( +
+ Loading... +
+ ); + } + + return null; + }} +
+ + + + dashboardJson.value ?? ''} + > + Copy to Clipboard + + + + + )} ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index eb90b340ad5..6ddc36f82b7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2649,9 +2649,11 @@ "back-button": "Back to export config", "cancel-button": "Cancel", "info-text": "Export this dashboard.", + "loading": "Loading...", "save-button": "Save to file", "share-externally-label": "Export for sharing externally", - "view-button": "View JSON" + "view-button": "View JSON", + "view-button-schemav2": "View JSON SchemaV2" }, "library": { "info": "Create library panel." diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8950fe5a584..b24be9678e2 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2649,9 +2649,11 @@ "back-button": "ßäčĸ ŧő ęχpőřŧ čőʼnƒįģ", "cancel-button": "Cäʼnčęľ", "info-text": "Ēχpőřŧ ŧĥįş đäşĥþőäřđ.", + "loading": "Ŀőäđįʼnģ...", "save-button": "Ŝävę ŧő ƒįľę", "share-externally-label": "Ēχpőřŧ ƒőř şĥäřįʼnģ ęχŧęřʼnäľľy", - "view-button": "Vįęŵ ĴŜØŃ" + "view-button": "Vįęŵ ĴŜØŃ", + "view-button-schemav2": "Vįęŵ ĴŜØŃ ŜčĥęmäV2" }, "library": { "info": "Cřęäŧę ľįþřäřy päʼnęľ." From 7e442efa9c55fe4b8bf663c85357e6b17630ab9e Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Tue, 19 Nov 2024 13:18:44 +0100 Subject: [PATCH 13/60] Zanzana: sync roles and bindings (#96661) * Sync fix and custom roles and team bindings. * Add collector for user role bindings --- .../accesscontrol/dualwrite/collectors.go | 209 +++++++++++++----- .../accesscontrol/dualwrite/reconciler.go | 16 +- 2 files changed, 163 insertions(+), 62 deletions(-) diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index a48219bab0a..97d5c6ee6f4 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -115,7 +115,7 @@ func folderTreeCollector(store db.DB) legacyTupleCollector { func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector { return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { query := ` - SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id + SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id, br.role as basic_role_name FROM permission p INNER JOIN role r ON p.role_id = r.id LEFT JOIN user_role ur ON r.id = ur.role_id @@ -128,12 +128,12 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector AND p.kind = ? ` type Permission struct { - RoleName string `xorm:"role_name"` - Action string `xorm:"action"` - Kind string - Identifier string - UserUID string `xorm:"user_uid"` - TeamUID string `xorm:"team_uid"` + Action string `xorm:"action"` + Kind string + Identifier string + UserUID string `xorm:"user_uid"` + TeamUID string `xorm:"team_uid"` + BasicRoleName string `xorm:"basic_role_name"` } var permissions []Permission @@ -152,10 +152,9 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector if len(p.UserUID) > 0 { subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "") } else if len(p.TeamUID) > 0 { - subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member") + subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, zanzana.RelationTeamMember) } else { - // FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place - continue + subject = zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(p.BasicRoleName), zanzana.RelationAssignee) } tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier) @@ -195,53 +194,7 @@ func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string { return s } -// basicRolePermissionsCollector collects permissions for basic roles -func basicRolePermissionsCollector(store db.DB) legacyTupleCollector { - return func(ctx context.Context, _ int64) (map[string]map[string]*openfgav1.TupleKey, error) { - const query = ` - SELECT r.uid as role_uid, p.action, p.kind, p.identifier - FROM permission p - INNER JOIN role r ON p.role_id = r.id - LEFT JOIN builtin_role br ON r.id = br.role_id - WHERE r.name LIKE 'basic:%' - ` - type Permission struct { - Action string `xorm:"action"` - Kind string - Identifier string - RoleUID string `xorm:"role_uid"` - } - - var permissions []Permission - err := store.WithDbSession(ctx, func(sess *db.Session) error { - return sess.SQL(query).Find(&permissions) - }) - if err != nil { - return nil, err - } - - tuples := make(map[string]map[string]*openfgav1.TupleKey) - - for _, p := range permissions { - subject := zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee") - - tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier) - if !ok { - continue - } - - if tuples[tuple.Object] == nil { - tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) - } - - tuples[tuple.Object][tuple.String()] = tuple - } - - return tuples, nil - } -} - -// basicRoleBindingsCollects collects role bindings for basic roles +// basicRoleBindingsCollector collects role bindings for basic roles func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { query := ` @@ -269,10 +222,8 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { tuples := make(map[string]map[string]*openfgav1.TupleKey) for _, b := range bindings { - subject := zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, "") - tuple := &openfgav1.TupleKey{ - User: subject, + User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""), Relation: zanzana.RelationAssignee, Object: zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(b.OrgRole), ""), } @@ -288,6 +239,144 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { } } +func teamRoleBindingsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + query := ` + SELECT t.uid AS team_uid, r.uid AS role_uid + FROM team_role tr + INNER JOIN team t ON tr.team_id = t.id + INNER JOIN role r ON tr.role_id = r.id + WHERE t.org_id = ? + AND r.name NOT LIKE 'managed:%' + ` + type Binding struct { + TeamUID string `xorm:"team_uid"` + RoleUID string `xorm:"role_uid"` + } + + var bindings []Binding + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&bindings) + }) + + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, b := range bindings { + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeTeam, b.TeamUID, zanzana.RelationTeamMember), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""), + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + +func userRoleBindingsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + query := ` + SELECT u.uid AS user_uid, r.uid AS role_uid + FROM user_role ur + INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON ur.user_id = u.id + INNER JOIN role r ON ur.role_id = r.id + WHERE (ur.org_id = 0 OR ur.org_id = ?) + AND r.name NOT LIKE 'managed:%' + ` + type Binding struct { + UserUID string `xorm:"user_uid"` + RoleUID string `xorm:"role_uid"` + } + + var bindings []Binding + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&bindings) + }) + + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, b := range bindings { + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""), + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + +func rolePermissionsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + var query = ` + SELECT r.uid as role_uid, p.action, p.kind, p.identifier + FROM permission p + INNER JOIN role r ON p.role_id = r.id + LEFT JOIN builtin_role br ON r.id = br.role_id + WHERE (r.org_id = 0 OR r.org_id = ?) + AND r.name NOT LIKE 'managed:%' + ` + + type Permission struct { + Action string `xorm:"action"` + Kind string + Identifier string + RoleUID string `xorm:"role_uid"` + } + + var permissions []Permission + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&permissions) + }) + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, p := range permissions { + tuple, ok := zanzana.TranslateToResourceTuple( + zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee), + p.Action, + p.Kind, + p.Identifier, + ) + if !ok { + continue + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + func zanzanaCollector(relations []string) zanzanaTupleCollector { return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { // list will use continuation token to collect all tuples for object and relation diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index 82364c8c259..2f1fe07ec2d 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -65,8 +65,8 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB, client, ), newResourceReconciler( - "basic role permissions", - basicRolePermissionsCollector(store), + "role permissions", + rolePermissionsCollector(store), zanzanaCollector(zanzana.FolderRelations), client, ), @@ -76,6 +76,18 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB, zanzanaCollector([]string{zanzana.RelationAssignee}), client, ), + newResourceReconciler( + "team role bindings", + teamRoleBindingsCollector(store), + zanzanaCollector([]string{zanzana.RelationAssignee}), + client, + ), + newResourceReconciler( + "user role bindings", + userRoleBindingsCollector(store), + zanzanaCollector([]string{zanzana.RelationAssignee}), + client, + ), }, } } From 6571451a5725b7466eaffe22e55b84dcc613fdca Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 19 Nov 2024 05:32:05 -0700 Subject: [PATCH 14/60] K8s: dashboards: fix conversion (#96648) --- .../apis/dashboard/v0alpha1/register.go | 23 ++++++++++++++++--- .../apis/dashboard/v1alpha1/register.go | 23 ++++++++++++++++--- .../apis/dashboard/v2alpha1/register.go | 23 ++++++++++++++++--- pkg/storage/unified/apistore/store.go | 15 +++++++++--- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index e6b5e1f9bd9..804d5e4e02d 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -12,6 +12,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -114,14 +115,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/registry/apis/dashboard/v1alpha1/register.go b/pkg/registry/apis/dashboard/v1alpha1/register.go index 74b0d08c684..0744ba56573 100644 --- a/pkg/registry/apis/dashboard/v1alpha1/register.go +++ b/pkg/registry/apis/dashboard/v1alpha1/register.go @@ -10,6 +10,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/registry/apis/dashboard/v2alpha1/register.go b/pkg/registry/apis/dashboard/v2alpha1/register.go index 6195651cb0e..0484201f79c 100644 --- a/pkg/registry/apis/dashboard/v2alpha1/register.go +++ b/pkg/registry/apis/dashboard/v2alpha1/register.go @@ -10,6 +10,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 8b19007dd8b..a33b602cdc1 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -44,6 +44,7 @@ var _ storage.Interface = (*Storage)(nil) // Optional settings that apply to a single resource type StorageOptions struct { LargeObjectSupport LargeObjectSupport + InternalConversion func([]byte, runtime.Object) (runtime.Object, error) } // Storage implements storage.Interface and storage resources as JSON files on disk. @@ -132,6 +133,14 @@ func (s *Storage) Versioner() storage.Versioner { return s.versioner } +func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) { + if s.opts.InternalConversion != nil { + return s.opts.InternalConversion(data, obj) + } + obj, _, err := s.codec.Decode(data, nil, obj) + return obj, err +} + // Create adds a new object at a key unless it already exists. 'ttl' is time-to-live // in seconds (0 means forever). If no error is returned and out is not nil, out will be // set to the read value from database. @@ -310,7 +319,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, return resource.GetError(rsp.Error) } - _, _, err = s.codec.Decode(rsp.Value, nil, objPtr) + _, err = s.convertToObject(rsp.Value, objPtr) if err != nil { return err } @@ -360,7 +369,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti } for _, item := range rsp.Items { - obj, _, err := s.codec.Decode(item.Value, nil, s.newFunc()) + obj, err := s.convertToObject(item.Value, s.newFunc()) if err != nil { return err } @@ -461,7 +470,7 @@ func (s *Storage) GuaranteedUpdate( existingObj = s.newFunc() if len(rsp.Value) > 0 { created = false - _, _, err = s.codec.Decode(rsp.Value, nil, existingObj) + _, err = s.convertToObject(rsp.Value, existingObj) if err != nil { return err } From e270412dbf43bef815eebb43fe1948e240ea79a5 Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Tue, 19 Nov 2024 14:13:30 +0100 Subject: [PATCH 15/60] unistore: wire the authz client (#96632) * unistore: wire the authz client * rename dashboards.grafana.app into dashboard.grafana.app * wire the authz client * wire the authz client * resuse the Standalone constructor * configure default migration for resource folder * add tests * cleanup * add logging --- pkg/services/apiserver/standalone/runtime.go | 2 +- .../apiserver/standalone/runtime_test.go | 4 +- pkg/services/authz/client.go | 5 +- pkg/services/authz/mappers/rbac_mapper.go | 8 +- pkg/services/authz/server_test.go | 16 ++-- .../zanzana/server/server_batch_check_test.go | 10 +-- .../authz/zanzana/server/server_check_test.go | 10 +-- .../authz/zanzana/server/server_list_test.go | 8 +- pkg/storage/unified/client.go | 4 +- pkg/storage/unified/resource/access.go | 81 +++++++++++++++++++ pkg/storage/unified/resource/access_test.go | 62 ++++++++++++++ pkg/storage/unified/sql/server.go | 7 +- pkg/storage/unified/sql/service.go | 8 +- .../features/dashboard/api/dashboard_api.ts | 2 +- 14 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 pkg/storage/unified/resource/access_test.go diff --git a/pkg/services/apiserver/standalone/runtime.go b/pkg/services/apiserver/standalone/runtime.go index 7b8abcb6d93..f2284356d70 100644 --- a/pkg/services/apiserver/standalone/runtime.go +++ b/pkg/services/apiserver/standalone/runtime.go @@ -17,7 +17,7 @@ func (a RuntimeConfig) String() string { // Supported options are: // -// /=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true) +// /=true|false for a specific API group and version (e.g. dashboard.grafana.app/v0alpha1=true) // api/all=true|false controls all API versions // api/ga=true|false controls all API versions of the form v[0-9]+ // api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+ diff --git a/pkg/services/apiserver/standalone/runtime_test.go b/pkg/services/apiserver/standalone/runtime_test.go index 374920cfd0e..e07db7b62a6 100644 --- a/pkg/services/apiserver/standalone/runtime_test.go +++ b/pkg/services/apiserver/standalone/runtime_test.go @@ -8,11 +8,11 @@ import ( ) func TestReadRuntimeCOnfig(t *testing.T) { - out, err := ReadRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false") + out, err := ReadRuntimeConfig("all/all=true,dashboard.grafana.app/v0alpha1=false") require.NoError(t, err) require.Equal(t, []RuntimeConfig{ {Group: "all", Version: "all", Enabled: true}, - {Group: "dashboards.grafana.app", Version: "v0alpha1", Enabled: false}, + {Group: "dashboard.grafana.app", Version: "v0alpha1", Enabled: false}, }, out) require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0])) diff --git a/pkg/services/authz/client.go b/pkg/services/authz/client.go index d0074e9e7d2..a692abdcc9c 100644 --- a/pkg/services/authz/client.go +++ b/pkg/services/authz/client.go @@ -88,7 +88,10 @@ func ProvideStandaloneAuthZClient( return nil, err } - return newGrpcLegacyClient(authCfg) + if cfg.StackID == "" { + return newGrpcLegacyClient(authCfg) + } + return newCloudLegacyClient(authCfg) } func newInProcLegacyClient(server *legacyServer) (authzlib.AccessChecker, error) { diff --git a/pkg/services/authz/mappers/rbac_mapper.go b/pkg/services/authz/mappers/rbac_mapper.go index e12e5d0ebcc..fa9a1a1a4eb 100644 --- a/pkg/services/authz/mappers/rbac_mapper.go +++ b/pkg/services/authz/mappers/rbac_mapper.go @@ -2,10 +2,10 @@ package mappers type VerbToAction map[string]string // e.g. "get" -> "read" type ResourceVerbToAction map[string]VerbToAction // e.g. "dashboards" -> VerbToAction -type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboards.grafana.app" -> ResourceVerbToAction +type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboard.grafana.app" -> ResourceVerbToAction type ResourceToAttribute map[string]string // e.g. "dashboards" -> "uid" -type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboards.grafana.app" -> ResourceToAttribute +type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboard.grafana.app" -> ResourceToAttribute type K8sRbacMapper struct { DefaultActions VerbToAction @@ -28,8 +28,8 @@ func NewK8sRbacMapper() *K8sRbacMapper { }, DefaultAttribute: "uid", Actions: GroupResourceVerbToAction{ - "dashboards.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}}, - "folders.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}}, + "dashboard.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}}, + "folder.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}}, }, } } diff --git a/pkg/services/authz/server_test.go b/pkg/services/authz/server_test.go index 4f5514b1aa2..1bd617ce6ed 100644 --- a/pkg/services/authz/server_test.go +++ b/pkg/services/authz/server_test.go @@ -60,7 +60,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -74,7 +74,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -88,7 +88,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -106,7 +106,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Namespace: "org-2", }, @@ -131,7 +131,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Name: "dash1", Namespace: "org-2", }, @@ -141,7 +141,7 @@ func Test_legacyServer_Check(t *testing.T) { name: "should return error when verb is not set", req: &authzv1.CheckRequest{ Subject: "user:1", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -152,7 +152,7 @@ func Test_legacyServer_Check(t *testing.T) { name: "should return error when subject is not set", req: &authzv1.CheckRequest{ Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -164,7 +164,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "stacks-2", diff --git a/pkg/services/authz/zanzana/server/server_batch_check_test.go b/pkg/services/authz/zanzana/server/server_batch_check_test.go index 9b64be2651f..70c65b8ed44 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check_test.go +++ b/pkg/services/authz/zanzana/server/server_batch_check_test.go @@ -31,7 +31,7 @@ func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckIte } func testBatchCheck(t *testing.T, server *Server) { - t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["2"]) }) - t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) { + t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -54,7 +54,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.Len(t, res.Groups[groupPrefix].Items, 2) }) - t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -67,7 +67,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["2"]) }) - t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -82,7 +82,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["3"]) }) - t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { + t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, diff --git a/pkg/services/authz/zanzana/server/server_check_test.go b/pkg/services/authz/zanzana/server/server_check_test.go index 78fa4609dbb..72f80d5cea5 100644 --- a/pkg/services/authz/zanzana/server/server_check_test.go +++ b/pkg/services/authz/zanzana/server/server_check_test.go @@ -24,7 +24,7 @@ func testCheck(t *testing.T, server *Server) { } } - t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:1", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -35,13 +35,13 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) { + t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through namespace", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:2", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) }) - t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:3", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -52,7 +52,7 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:4", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -71,7 +71,7 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { + t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:5", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) diff --git a/pkg/services/authz/zanzana/server/server_list_test.go b/pkg/services/authz/zanzana/server/server_list_test.go index cc7ef17126c..778f286be3e 100644 --- a/pkg/services/authz/zanzana/server/server_list_test.go +++ b/pkg/services/authz/zanzana/server/server_list_test.go @@ -22,7 +22,7 @@ func testList(t *testing.T, server *Server) { } } - t.Run("user:1 should list resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should list resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:1", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 1) @@ -38,7 +38,7 @@ func testList(t *testing.T, server *Server) { assert.Len(t, res.GetFolders(), 0) }) - t.Run("user:3 should be able to list resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to list resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:3", dashboardGroup, dashboardResource)) require.NoError(t, err) @@ -47,7 +47,7 @@ func testList(t *testing.T, server *Server) { assert.Equal(t, res.GetItems()[0], "1") }) - t.Run("user:4 should be able to list all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to list all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:4", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 0) @@ -64,7 +64,7 @@ func testList(t *testing.T, server *Server) { assert.Equal(t, second, "3") }) - t.Run("user:5 should be get list all dashboards.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) { + t.Run("user:5 should be get list all dashboard.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:5", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 0) diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index c1cf0a2513b..9cdd69d6a19 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/authn/grpcutils" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -32,6 +33,7 @@ func ProvideUnifiedStorageClient( db infraDB.DB, tracer tracing.Tracer, reg prometheus.Registerer, + authzc authz.Client, ) (resource.ResourceClient, error) { // See: apiserver.ApplyGrafanaConfig(cfg, features, o) apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") @@ -95,7 +97,7 @@ func ProvideUnifiedStorageClient( // Use the local SQL default: - server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg) + server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg, authzc) if err != nil { return nil, err } diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index af6f2582d33..97781a7c9d8 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -2,6 +2,8 @@ package resource import ( "context" + "log/slog" + "time" "github.com/grafana/authlib/authz" "github.com/grafana/authlib/claims" @@ -24,3 +26,82 @@ func (c *staticAuthzClient) Compile(ctx context.Context, id claims.AuthInfo, req } var _ authz.AccessClient = &staticAuthzClient{} + +type groupResource map[string]map[string]interface{} + +// authzLimitedClient is a client that enforces RBAC for the limited number of groups and resources. +// This is a temporary solution until the authz service is fully implemented. +// The authz service will be responsible for enforcing RBAC. +// For now, it makes one call to the authz service for each list items. This is known to be inefficient. +type authzLimitedClient struct { + client authz.AccessChecker + // whitelist is a map of group to resources that are compatible with RBAC. + whitelist groupResource + logger *slog.Logger +} + +// NewAuthzLimitedClient creates a new authzLimitedClient. +func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient { + logger := slog.Default().With("logger", "limited-authz-client") + return &authzLimitedClient{ + client: client, + whitelist: groupResource{ + "dashboard.grafana.app": map[string]interface{}{"dashboards": nil}, + "folder.grafana.app": map[string]interface{}{"folders": nil}, + }, + logger: logger, + } +} + +// Check implements authz.AccessClient. +func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) { + if !c.IsCompatibleWithRBAC(req.Group, req.Resource) { + c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", false, "allowed", true) + return authz.CheckResponse{Allowed: true}, nil + } + t := time.Now() + resp, err := c.client.Check(ctx, id, req) + if err != nil { + c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "error", err, "duration", time.Since(t)) + return resp, err + } + c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t)) + return resp, nil +} + +// Compile implements authz.AccessClient. +func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) { + return func(namespace string, name, folder string) bool { + // TODO: Implement For now we perform the check for each item. + if !c.IsCompatibleWithRBAC(req.Group, req.Resource) { + c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", false, "allowed", true) + return true + } + t := time.Now() + r, err := c.client.Check(ctx, id, authz.CheckRequest{ + Verb: "get", + Group: req.Group, + Resource: req.Resource, + Namespace: namespace, + Name: name, + Folder: folder, + }) + if err != nil { + c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "error", err, "duration", time.Since(t)) + return false + } + c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t)) + return r.Allowed + }, nil +} + +func (c authzLimitedClient) IsCompatibleWithRBAC(group, resource string) bool { + if _, ok := c.whitelist[group]; ok { + if _, ok := c.whitelist[group][resource]; ok { + return true + } + } + return false +} + +var _ authz.AccessClient = &authzLimitedClient{} diff --git a/pkg/storage/unified/resource/access_test.go b/pkg/storage/unified/resource/access_test.go new file mode 100644 index 00000000000..129dd28f696 --- /dev/null +++ b/pkg/storage/unified/resource/access_test.go @@ -0,0 +1,62 @@ +package resource + +import ( + "context" + "testing" + + "github.com/grafana/authlib/authz" + "github.com/stretchr/testify/assert" +) + +func TestAuthzLimitedClient_Check(t *testing.T) { + mockClient := &staticAuthzClient{allowed: false} + client := NewAuthzLimitedClient(mockClient) + + tests := []struct { + group string + resource string + expected bool + }{ + {"dashboard.grafana.app", "dashboards", false}, + {"folder.grafana.app", "folders", false}, + {"unknown.group", "unknown.resource", true}, + } + + for _, test := range tests { + req := authz.CheckRequest{ + Group: test.group, + Resource: test.resource, + } + resp, err := client.Check(context.Background(), nil, req) + assert.NoError(t, err) + assert.Equal(t, test.expected, resp.Allowed) + } +} + +func TestAuthzLimitedClient_Compile(t *testing.T) { + mockClient := &staticAuthzClient{allowed: false} + client := NewAuthzLimitedClient(mockClient) + + tests := []struct { + group string + resource string + expected bool + }{ + {"dashboard.grafana.app", "dashboards", false}, + {"folder.grafana.app", "folders", false}, + {"unknown.group", "unknown.resource", true}, + } + + for _, test := range tests { + req := authz.ListRequest{ + Group: test.group, + Resource: test.resource, + } + checker, err := client.Compile(context.Background(), nil, req) + assert.NoError(t, err) + assert.NotNil(t, checker) + + result := checker("namespace", "name", "folder") + assert.Equal(t, test.expected, result) + } +} diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index e1b730d4744..ddbbd99ec7d 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -18,7 +19,7 @@ import ( ) // Creates a new ResourceServer -func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer, reg prometheus.Registerer) (resource.ResourceServer, error) { +func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer, reg prometheus.Registerer, ac authz.Client) (resource.ResourceServer, error) { apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") opts := resource.ResourceServerOptions{ Tracer: tracer, @@ -27,7 +28,9 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, fea }, Reg: reg, } - + if ac != nil { + opts.AccessClient = resource.NewAuthzLimitedClient(ac) + } // Support local file blob if strings.HasPrefix(opts.Blob.URL, "./data/") { dir := strings.Replace(opts.Blob.URL, "./data", cfg.DataPath, 1) diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 83105efb371..62cf7612f40 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/modules" "github.com/grafana/grafana/pkg/services/authn/grpcutils" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" @@ -93,7 +94,12 @@ func ProvideUnifiedStorageGrpcService( } func (s *service) start(ctx context.Context) error { - server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.tracing, s.reg) + authzClient, err := authz.ProvideStandaloneAuthZClient(s.cfg, s.features, s.tracing) + if err != nil { + return err + } + + server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.tracing, s.reg, authzClient) if err != nil { return err } diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 84c6f2e98f3..8d54bb66a0e 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -50,7 +50,7 @@ interface DashboardWithAccessInfo extends Resource; From 11a4a366c6feedcfc2f306034bbc55295b5fdd9a Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Tue, 19 Nov 2024 14:39:46 +0100 Subject: [PATCH 16/60] Zanzana: Optimize batch check (#96669) * Restructure check code so we only check namespace access once for each GroupResource during for batch --- pkg/services/authz/zanzana/common/info.go | 4 +- pkg/services/authz/zanzana/server/server.go | 4 +- .../zanzana/server/server_batch_check.go | 64 +++++----- .../authz/zanzana/server/server_check.go | 111 ++++++++---------- .../authz/zanzana/server/server_list.go | 94 ++++++--------- .../authz/zanzana/server/server_read.go | 2 +- .../authz/zanzana/server/server_store.go | 4 +- .../authz/zanzana/server/server_test.go | 4 +- .../authz/zanzana/server/server_write.go | 4 +- 9 files changed, 131 insertions(+), 160 deletions(-) diff --git a/pkg/services/authz/zanzana/common/info.go b/pkg/services/authz/zanzana/common/info.go index 3d2521875e8..ad5be54940a 100644 --- a/pkg/services/authz/zanzana/common/info.go +++ b/pkg/services/authz/zanzana/common/info.go @@ -11,14 +11,14 @@ type TypeInfo struct { } var typedResources = map[string]TypeInfo{ - NewNamespaceResourceIdent( + FormatGroupResource( folderalpha1.FolderResourceInfo.GroupResource().Group, folderalpha1.FolderResourceInfo.GroupResource().Resource, ): {Type: "folder"}, } func GetTypeInfo(group, resource string) (TypeInfo, bool) { - info, ok := typedResources[NewNamespaceResourceIdent(group, resource)] + info, ok := typedResources[FormatGroupResource(group, resource)] return info, ok } diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index 27f12cf6aad..faea365502d 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -37,8 +37,8 @@ type Server struct { } type storeInfo struct { - Id string - AuthorizationModelId string + ID string + ModelID string } type ServerOption func(s *Server) diff --git a/pkg/services/authz/zanzana/server/server_batch_check.go b/pkg/services/authz/zanzana/server/server_batch_check.go index 22b6b3ecec1..7c96df5ae79 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check.go +++ b/pkg/services/authz/zanzana/server/server_batch_check.go @@ -17,48 +17,58 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest Groups: make(map[string]*authzextv1.BatchCheckGroupResource), } - subject := r.GetSubject() + store, err := s.getStoreInfo(ctx, r.GetNamespace()) + if err != nil { + return nil, err + } - for _, item := range r.Items { - groupPrefix := common.FormatGroupResource(item.GetGroup(), item.GetResource()) - allowed, err := s.batchCheckItem(ctx, subject, r.Namespace, item) + groupResourceAccess := make(map[string]bool) + + for _, item := range r.GetItems() { + res, err := s.batchCheckItem(ctx, r, item, store, groupResourceAccess) if err != nil { return nil, err } - if _, ok := batchRes.Groups[groupPrefix]; !ok { - batchRes.Groups[groupPrefix] = &authzextv1.BatchCheckGroupResource{ + groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource()) + if _, ok := batchRes.Groups[groupResource]; !ok { + batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{ Items: make(map[string]bool), } } - batchRes.Groups[groupPrefix].Items[item.GetName()] = allowed + batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed() } return batchRes, nil } -func (s *Server) batchCheckItem(ctx context.Context, subject string, namespace string, item *authzextv1.BatchCheckItem) (bool, error) { - req := &authzv1.CheckRequest{ - Namespace: namespace, - Subject: subject, - Verb: item.GetVerb(), - Group: item.GetGroup(), - Resource: item.GetResource(), - Name: item.GetName(), - Folder: item.GetFolder(), - Subresource: item.GetSubresource(), +func (s *Server) batchCheckItem( + ctx context.Context, + r *authzextv1.BatchCheckRequest, + item *authzextv1.BatchCheckItem, + store *storeInfo, + groupResourceAccess map[string]bool, +) (*authzv1.CheckResponse, error) { + var ( + relation = common.VerbMapping[item.GetVerb()] + groupResource = common.FormatGroupResource(item.GetGroup(), item.GetResource()) + ) + + allowed, ok := groupResourceAccess[groupResource] + if !ok { + res, err := s.checkNamespace(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store) + if err != nil { + return nil, err + } + groupResourceAccess[groupResource] = res.GetAllowed() + } + + if allowed { + return &authzv1.CheckResponse{Allowed: true}, nil } - var res *authzv1.CheckResponse - var err error if info, ok := common.GetTypeInfo(item.GetGroup(), item.GetResource()); ok { - res, err = s.checkTyped(ctx, req, info) - } else { - res, err = s.checkGeneric(ctx, req) + return s.checkTyped(ctx, r.GetSubject(), relation, item.GetName(), info, store) } - if err != nil { - return false, err - } - - return res.Allowed, nil + return s.checkGeneric(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), item.GetName(), item.GetFolder(), store) } diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index f0e78045292..cad15cb17ef 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -14,28 +14,39 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C ctx, span := tracer.Start(ctx, "authzServer.Check") defer span.End() - if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { - return s.checkTyped(ctx, r, info) - } - return s.checkGeneric(ctx, r) -} - -// checkNamespace checks if subject has access through namespace -func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) + store, err := s.getStoreInfo(ctx, r.GetNamespace()) if err != nil { return nil, err } relation := common.VerbMapping[r.GetVerb()] + // Check if subject has access through namespace + res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store) + if err != nil { + return nil, err + } + + if res.GetAllowed() { + return res, nil + } + + if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { + return s.checkTyped(ctx, r.GetSubject(), relation, r.GetName(), info, store) + } + return s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store) +} + +// checkTyped performes check on the root "namespace". If subject has access through the namespace they have access to +// every resource for that "GroupResource". +func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) { res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), + Object: common.NewNamespaceResourceIdent(group, resource), }, }) if err != nil { @@ -45,22 +56,16 @@ func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (* return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil } -func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } - - relation := common.VerbMapping[r.GetVerb()] - - // 1. check if subject has direct access to resource +// checkTyped performes checks on our typed resources e.g. folder. +func (s *Server) checkTyped(ctx context.Context, subject, relation, name string, info common.TypeInfo, store *storeInfo) (*authzv1.CheckResponse, error) { + // Check if subject has direct access to resource res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewTypedIdent(info.Type, r.GetName()), + Object: common.NewTypedIdent(info.Type, name), }, }) if err != nil { @@ -71,40 +76,30 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info c return &authzv1.CheckResponse{Allowed: true}, nil } - // 2. check if subject has access through namespace - nsRes, err := s.checkNamespace(ctx, r) - if err != nil { - return nil, err - } - - return &authzv1.CheckResponse{Allowed: nsRes.GetAllowed()}, nil + return &authzv1.CheckResponse{Allowed: false}, nil } -func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } +// checkGeneric check our generic "resource" type. +func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, resource, name, folder string, store *storeInfo) (*authzv1.CheckResponse, error) { + groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource)) - relation := common.VerbMapping[r.GetVerb()] - // 1. check if subject has direct access to resource + // Check if subject has direct access to resource res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()), + Object: common.NewResourceIdent(group, resource, name), }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) if err != nil { - // FIXME: wrap error return nil, err } @@ -112,32 +107,22 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au return &authzv1.CheckResponse{Allowed: true}, nil } - // 2. check if subject has access through namespace - nsRes, err := s.checkNamespace(ctx, r) - if err != nil { - return nil, err - } - - if nsRes.GetAllowed() { - return &authzv1.CheckResponse{Allowed: true}, nil - } - - if r.Folder == "" { + if folder == "" { return &authzv1.CheckResponse{Allowed: false}, nil } - // 3. check if subject has access as a sub resource for the folder + // Check if subject has access as a sub resource for the folder res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: common.FolderResourceRelation(relation), - Object: common.NewFolderIdent(r.GetFolder()), + Object: common.NewFolderIdent(folder), }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index a874ac5feee..2ee7f720370 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -16,31 +16,22 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext ctx, span := tracer.Start(ctx, "authzServer.List") defer span.End() - if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { - return s.listTyped(ctx, r, info) - } - - return s.listGeneric(ctx, r) -} - -func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info common.TypeInfo) (*authzextv1.ListResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) + store, err := s.getStoreInfo(ctx, r.Namespace) if err != nil { return nil, err } relation := common.VerbMapping[r.GetVerb()] - // 1. check if subject has access through namespace because then they can read all of them - res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) + res, err := s.checkNamespace( + ctx, + r.GetSubject(), + relation, + r.GetGroup(), + r.GetResource(), + store, + ) + if err != nil { return nil, err } @@ -49,13 +40,21 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info return &authzextv1.ListResponse{All: true}, nil } - // 2. List all resources user has access too + if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { + return s.listTyped(ctx, r.GetSubject(), relation, info, store) + } + + return s.listGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store) +} + +func (s *Server) listTyped(ctx context.Context, subject, relation string, info common.TypeInfo, store *storeInfo) (*authzextv1.ListResponse, error) { + // List all resources user has access too listRes, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: info.Type, Relation: relation, - User: r.GetSubject(), + User: subject, }) if err != nil { return nil, err @@ -66,42 +65,19 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info }, nil } -func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*authzextv1.ListResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } +func (s *Server) listGeneric(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzextv1.ListResponse, error) { + groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource)) - relation := common.VerbMapping[r.GetVerb()] - - // 1. check if subject has access through namespace because then they can read all of them - res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) - if err != nil { - return nil, err - } - - if res.Allowed { - return &authzextv1.ListResponse{All: true}, nil - } - - // 2. List all folders subject has access to resource type in + // 1. List all folders subject has access to resource type in folders, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: common.TypeFolder, Relation: common.FolderResourceRelation(relation), - User: r.GetSubject(), + User: subject, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) @@ -109,16 +85,16 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a return nil, err } - // 3. List all resource directly assigned to subject + // 2. List all resource directly assigned to subject direct, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: common.TypeResource, Relation: relation, - User: r.GetSubject(), + User: subject, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) @@ -128,7 +104,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a return &authzextv1.ListResponse{ Folders: folderObject(folders.GetObjects()), - Items: directObjects(r.GetGroup(), r.GetResource(), direct.GetObjects()), + Items: directObjects(group, resource, direct.GetObjects()), }, nil } diff --git a/pkg/services/authz/zanzana/server/server_read.go b/pkg/services/authz/zanzana/server/server_read.go index eafecdf5f76..83fec6f15f7 100644 --- a/pkg/services/authz/zanzana/server/server_read.go +++ b/pkg/services/authz/zanzana/server/server_read.go @@ -19,7 +19,7 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze } res, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{ - StoreId: storeInf.Id, + StoreId: storeInf.ID, TupleKey: &openfgav1.ReadRequestTupleKey{ User: req.GetTupleKey().GetUser(), Relation: req.GetTupleKey().GetRelation(), diff --git a/pkg/services/authz/zanzana/server/server_store.go b/pkg/services/authz/zanzana/server/server_store.go index 8af1ca98ef9..7ae0e3113f6 100644 --- a/pkg/services/authz/zanzana/server/server_store.go +++ b/pkg/services/authz/zanzana/server/server_store.go @@ -30,8 +30,8 @@ func (s *Server) getStoreInfo(ctx context.Context, namespace string) (*storeInfo } info = storeInfo{ - Id: store.GetId(), - AuthorizationModelId: modelID, + ID: store.GetId(), + ModelID: modelID, } s.stores[namespace] = info diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index afa49bb6757..5e2d5efb62f 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -71,8 +71,8 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { // seed tuples _, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, Writes: &openfgav1.WriteRequestWrites{ TupleKeys: []*openfgav1.TupleKey{ common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"), diff --git a/pkg/services/authz/zanzana/server/server_write.go b/pkg/services/authz/zanzana/server/server_write.go index a8c76c0ebe3..ec99e047123 100644 --- a/pkg/services/authz/zanzana/server/server_write.go +++ b/pkg/services/authz/zanzana/server/server_write.go @@ -29,8 +29,8 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth } writeReq := &openfgav1.WriteRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, } if len(writeTuples) > 0 { writeReq.Writes = &openfgav1.WriteRequestWrites{ From 7985fa573e5c3e2e23ece6ba455ee0becec3dc64 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 19 Nov 2024 16:47:59 +0300 Subject: [PATCH 17/60] UnifiedStorage: Add ResourceTable format (#96506) --- pkg/storage/unified/resource/resource.pb.go | 957 +++++++++++++----- pkg/storage/unified/resource/resource.proto | 148 ++- pkg/storage/unified/resource/table.go | 549 ++++++++++ pkg/storage/unified/resource/table_test.go | 343 +++++++ .../resource/testdata/simple-table.json | 72 ++ 5 files changed, 1825 insertions(+), 244 deletions(-) create mode 100644 pkg/storage/unified/resource/table.go create mode 100644 pkg/storage/unified/resource/table_test.go create mode 100644 pkg/storage/unified/resource/testdata/simple-table.json diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index 1fa3f1e6c33..a247d97232c 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -176,6 +176,81 @@ func (HealthCheckResponse_ServingStatus) EnumDescriptor() ([]byte, []int) { return file_resource_proto_rawDescGZIP(), []int{30, 0} } +// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. +// When converted to a k8s Table, this will become two fields: type and format +type ResourceTableColumnDefinition_ColumnType int32 + +const ( + ResourceTableColumnDefinition_UNKNOWN_TYPE ResourceTableColumnDefinition_ColumnType = 0 + ResourceTableColumnDefinition_STRING ResourceTableColumnDefinition_ColumnType = 1 + ResourceTableColumnDefinition_BOOLEAN ResourceTableColumnDefinition_ColumnType = 2 + ResourceTableColumnDefinition_INT32 ResourceTableColumnDefinition_ColumnType = 3 + ResourceTableColumnDefinition_INT64 ResourceTableColumnDefinition_ColumnType = 4 + ResourceTableColumnDefinition_FLOAT ResourceTableColumnDefinition_ColumnType = 5 + ResourceTableColumnDefinition_DOUBLE ResourceTableColumnDefinition_ColumnType = 6 + ResourceTableColumnDefinition_DATE ResourceTableColumnDefinition_ColumnType = 7 + ResourceTableColumnDefinition_DATE_TIME ResourceTableColumnDefinition_ColumnType = 8 + ResourceTableColumnDefinition_BINARY ResourceTableColumnDefinition_ColumnType = 9 + ResourceTableColumnDefinition_OBJECT ResourceTableColumnDefinition_ColumnType = 10 // map[string]any +) + +// Enum value maps for ResourceTableColumnDefinition_ColumnType. +var ( + ResourceTableColumnDefinition_ColumnType_name = map[int32]string{ + 0: "UNKNOWN_TYPE", + 1: "STRING", + 2: "BOOLEAN", + 3: "INT32", + 4: "INT64", + 5: "FLOAT", + 6: "DOUBLE", + 7: "DATE", + 8: "DATE_TIME", + 9: "BINARY", + 10: "OBJECT", + } + ResourceTableColumnDefinition_ColumnType_value = map[string]int32{ + "UNKNOWN_TYPE": 0, + "STRING": 1, + "BOOLEAN": 2, + "INT32": 3, + "INT64": 4, + "FLOAT": 5, + "DOUBLE": 6, + "DATE": 7, + "DATE_TIME": 8, + "BINARY": 9, + "OBJECT": 10, + } +) + +func (x ResourceTableColumnDefinition_ColumnType) Enum() *ResourceTableColumnDefinition_ColumnType { + p := new(ResourceTableColumnDefinition_ColumnType) + *p = x + return p +} + +func (x ResourceTableColumnDefinition_ColumnType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ResourceTableColumnDefinition_ColumnType) Descriptor() protoreflect.EnumDescriptor { + return file_resource_proto_enumTypes[3].Descriptor() +} + +func (ResourceTableColumnDefinition_ColumnType) Type() protoreflect.EnumType { + return &file_resource_proto_enumTypes[3] +} + +func (x ResourceTableColumnDefinition_ColumnType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ResourceTableColumnDefinition_ColumnType.Descriptor instead. +func (ResourceTableColumnDefinition_ColumnType) EnumDescriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32, 0} +} + type PutBlobRequest_Method int32 const ( @@ -208,11 +283,11 @@ func (x PutBlobRequest_Method) String() string { } func (PutBlobRequest_Method) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[3].Descriptor() + return file_resource_proto_enumTypes[4].Descriptor() } func (PutBlobRequest_Method) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[3] + return &file_resource_proto_enumTypes[4] } func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { @@ -221,7 +296,7 @@ func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { // Deprecated: Use PutBlobRequest_Method.Descriptor instead. func (PutBlobRequest_Method) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{31, 0} + return file_resource_proto_rawDescGZIP(), []int{34, 0} } type ResourceKey struct { @@ -2377,6 +2452,275 @@ func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus { return HealthCheckResponse_UNKNOWN } +// ResourceTable is a protobuf variation of the kubernetes Table object. +// This format allows specifying a flexible set of columns related to a given resource +type ResourceTable struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Columns describes each column in the returned items array. The number of cells per row + // will always match the number of column definitions. + Columns []*ResourceTableColumnDefinition `protobuf:"bytes,1,rep,name=columns,proto3" json:"columns,omitempty"` + // rows is the list of items in the table. + Rows []*ResourceTableRow `protobuf:"bytes,2,rep,name=rows,proto3" json:"rows,omitempty"` + // When more results exist, pass this in the next request + NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // ResourceVersion of the list response + // +optional + ResourceVersion int64 `protobuf:"varint,4,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + // remainingItemCount is the number of subsequent items in the list which are not included in this + // list response. If the list request contained label or field selectors, then the number of + // remaining items is unknown and the field will be left unset and omitted during serialization. + // If the list is complete (either because it is not chunking or because this is the last chunk), + // then there are no more remaining items and this field will be left unset and omitted during + // serialization. + // + // The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + // should not rely on the remainingItemCount to be set or to be exact. + // +optional + RemainingItemCount int64 `protobuf:"varint,5,opt,name=remaining_item_count,json=remainingItemCount,proto3" json:"remaining_item_count,omitempty"` +} + +func (x *ResourceTable) Reset() { + *x = ResourceTable{} + mi := &file_resource_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTable) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTable) ProtoMessage() {} + +func (x *ResourceTable) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTable.ProtoReflect.Descriptor instead. +func (*ResourceTable) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{31} +} + +func (x *ResourceTable) GetColumns() []*ResourceTableColumnDefinition { + if x != nil { + return x.Columns + } + return nil +} + +func (x *ResourceTable) GetRows() []*ResourceTableRow { + if x != nil { + return x.Rows + } + return nil +} + +func (x *ResourceTable) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *ResourceTable) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +func (x *ResourceTable) GetRemainingItemCount() int64 { + if x != nil { + return x.RemainingItemCount + } + return 0 +} + +// TableColumnDefinition contains information about a column returned in the Table. +type ResourceTableColumnDefinition struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // name is a human readable name for the column. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Defines the column type. In k8s, this will resolve into both the type and format fields + Type ResourceTableColumnDefinition_ColumnType `protobuf:"varint,2,opt,name=type,proto3,enum=resource.ResourceTableColumnDefinition_ColumnType" json:"type,omitempty"` + // The value is an arry of given type + IsArray bool `protobuf:"varint,3,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"` + // description is a human readable description of this column. + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + // Properties about this column (helpful for indexing and search) + Properties *ResourceTableColumnDefinition_Properties `protobuf:"bytes,5,opt,name=properties,proto3" json:"properties,omitempty"` + // priority is an integer defining the relative importance of this column compared to others. Lower + // numbers are considered higher priority. Columns that may be omitted in limited space scenarios + // should be given a higher priority. + Priority int32 `protobuf:"varint,6,opt,name=priority,proto3" json:"priority,omitempty"` +} + +func (x *ResourceTableColumnDefinition) Reset() { + *x = ResourceTableColumnDefinition{} + mi := &file_resource_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableColumnDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableColumnDefinition) ProtoMessage() {} + +func (x *ResourceTableColumnDefinition) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableColumnDefinition.ProtoReflect.Descriptor instead. +func (*ResourceTableColumnDefinition) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32} +} + +func (x *ResourceTableColumnDefinition) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ResourceTableColumnDefinition) GetType() ResourceTableColumnDefinition_ColumnType { + if x != nil { + return x.Type + } + return ResourceTableColumnDefinition_UNKNOWN_TYPE +} + +func (x *ResourceTableColumnDefinition) GetIsArray() bool { + if x != nil { + return x.IsArray + } + return false +} + +func (x *ResourceTableColumnDefinition) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ResourceTableColumnDefinition) GetProperties() *ResourceTableColumnDefinition_Properties { + if x != nil { + return x.Properties + } + return nil +} + +func (x *ResourceTableColumnDefinition) GetPriority() int32 { + if x != nil { + return x.Priority + } + return 0 +} + +// TableRow is an individual row in a table. +type ResourceTableRow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The resource referenced by this row + Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // The resource version for the given values + ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + // Cells will be as wide as the column definitions array + // Numeric values will be encoded using big endian bytes + // All arrays will be JSON encoded + Cells [][]byte `protobuf:"bytes,3,rep,name=cells,proto3" json:"cells,omitempty"` + // This field may contains the additional information about each object based on the request. + // The value will be at least a partial object metadata, and perhaps the full object metadata. + // When this value exists, it should include both the key and the resource_version otherwise + // they may be lost in the conversion to k8s resource + // +optional + Object []byte `protobuf:"bytes,4,opt,name=object,proto3" json:"object,omitempty"` +} + +func (x *ResourceTableRow) Reset() { + *x = ResourceTableRow{} + mi := &file_resource_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableRow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableRow) ProtoMessage() {} + +func (x *ResourceTableRow) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableRow.ProtoReflect.Descriptor instead. +func (*ResourceTableRow) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{33} +} + +func (x *ResourceTableRow) GetKey() *ResourceKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *ResourceTableRow) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +func (x *ResourceTableRow) GetCells() [][]byte { + if x != nil { + return x.Cells + } + return nil +} + +func (x *ResourceTableRow) GetObject() []byte { + if x != nil { + return x.Object + } + return nil +} + type PutBlobRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2396,7 +2740,7 @@ type PutBlobRequest struct { func (x *PutBlobRequest) Reset() { *x = PutBlobRequest{} - mi := &file_resource_proto_msgTypes[31] + mi := &file_resource_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2408,7 +2752,7 @@ func (x *PutBlobRequest) String() string { func (*PutBlobRequest) ProtoMessage() {} func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[31] + mi := &file_resource_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2421,7 +2765,7 @@ func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobRequest.ProtoReflect.Descriptor instead. func (*PutBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{31} + return file_resource_proto_rawDescGZIP(), []int{34} } func (x *PutBlobRequest) GetResource() *ResourceKey { @@ -2475,7 +2819,7 @@ type PutBlobResponse struct { func (x *PutBlobResponse) Reset() { *x = PutBlobResponse{} - mi := &file_resource_proto_msgTypes[32] + mi := &file_resource_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2487,7 +2831,7 @@ func (x *PutBlobResponse) String() string { func (*PutBlobResponse) ProtoMessage() {} func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[32] + mi := &file_resource_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2500,7 +2844,7 @@ func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobResponse.ProtoReflect.Descriptor instead. func (*PutBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{32} + return file_resource_proto_rawDescGZIP(), []int{35} } func (x *PutBlobResponse) GetError() *ErrorResult { @@ -2566,7 +2910,7 @@ type GetBlobRequest struct { func (x *GetBlobRequest) Reset() { *x = GetBlobRequest{} - mi := &file_resource_proto_msgTypes[33] + mi := &file_resource_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2578,7 +2922,7 @@ func (x *GetBlobRequest) String() string { func (*GetBlobRequest) ProtoMessage() {} func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[33] + mi := &file_resource_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2591,7 +2935,7 @@ func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead. func (*GetBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{33} + return file_resource_proto_rawDescGZIP(), []int{36} } func (x *GetBlobRequest) GetResource() *ResourceKey { @@ -2634,7 +2978,7 @@ type GetBlobResponse struct { func (x *GetBlobResponse) Reset() { *x = GetBlobResponse{} - mi := &file_resource_proto_msgTypes[34] + mi := &file_resource_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2646,7 +2990,7 @@ func (x *GetBlobResponse) String() string { func (*GetBlobResponse) ProtoMessage() {} func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[34] + mi := &file_resource_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2659,7 +3003,7 @@ func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobResponse.ProtoReflect.Descriptor instead. func (*GetBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{34} + return file_resource_proto_rawDescGZIP(), []int{37} } func (x *GetBlobResponse) GetError() *ErrorResult { @@ -2701,7 +3045,7 @@ type WatchEvent_Resource struct { func (x *WatchEvent_Resource) Reset() { *x = WatchEvent_Resource{} - mi := &file_resource_proto_msgTypes[35] + mi := &file_resource_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2713,7 +3057,7 @@ func (x *WatchEvent_Resource) String() string { func (*WatchEvent_Resource) ProtoMessage() {} func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[35] + mi := &file_resource_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2743,6 +3087,92 @@ func (x *WatchEvent_Resource) GetValue() []byte { return nil } +// These values are not part of standard k8s format +// however these are useful when indexing and analyzing results +type ResourceTableColumnDefinition_Properties struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // All values in this columns should be unique + UniqueValues bool `protobuf:"varint,1,opt,name=unique_values,json=uniqueValues,proto3" json:"unique_values,omitempty"` + // The string value is free text; using text analyzers is appropriate + FreeText bool `protobuf:"varint,2,opt,name=free_text,json=freeText,proto3" json:"free_text,omitempty"` + // The value(s) are reasonable to use for search refinement + // When indexing, these values would be good to add to an index + Filterable bool `protobuf:"varint,3,opt,name=filterable,proto3" json:"filterable,omitempty"` + // When true, every value should exist + // not_null with a nil default_value should be an error + NotNull bool `protobuf:"varint,4,opt,name=not_null,json=notNull,proto3" json:"not_null,omitempty"` + // When missing, this value can be used + DefaultValue []byte `protobuf:"bytes,5,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` +} + +func (x *ResourceTableColumnDefinition_Properties) Reset() { + *x = ResourceTableColumnDefinition_Properties{} + mi := &file_resource_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableColumnDefinition_Properties) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableColumnDefinition_Properties) ProtoMessage() {} + +func (x *ResourceTableColumnDefinition_Properties) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableColumnDefinition_Properties.ProtoReflect.Descriptor instead. +func (*ResourceTableColumnDefinition_Properties) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32, 0} +} + +func (x *ResourceTableColumnDefinition_Properties) GetUniqueValues() bool { + if x != nil { + return x.UniqueValues + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetFreeText() bool { + if x != nil { + return x.FreeText + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetFilterable() bool { + if x != nil { + return x.Filterable + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetNotNull() bool { + if x != nil { + return x.NotNull + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetDefaultValue() []byte { + if x != nil { + return x.DefaultValue + } + return nil +} + var File_resource_proto protoreflect.FileDescriptor var file_resource_proto_rawDesc = []byte{ @@ -3028,109 +3458,174 @@ var file_resource_proto_rawDesc = []byte{ 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, - 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0xd3, - 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, - 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, - 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, - 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, - 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, - 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0x98, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, - 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, - 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x75, 0x73, - 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x42, 0x79, - 0x74, 0x65, 0x73, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2a, - 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, - 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, - 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, - 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, - 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, - 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x18, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x17, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0x87, + 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, + 0x6d, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52, 0x04, 0x72, + 0x6f, 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, + 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, + 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, + 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, + 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, + 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70, 0x72, 0x6f, + 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, + 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, + 0x69, 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, + 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, + 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x65, 0x5f, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72, 0x65, 0x65, + 0x54, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, + 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x61, 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, + 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, + 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f, 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02, 0x12, 0x09, + 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, + 0x36, 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10, 0x05, 0x12, + 0x0a, 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42, 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a, 0x04, 0x44, + 0x41, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d, 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x49, + 0x4d, 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x09, + 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94, 0x01, 0x0a, + 0x10, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, + 0x77, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x22, 0xd3, 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, + 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, - 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, - 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, - 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, - 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, - 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, - 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, + 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, + 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, + 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, + 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, + 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0x98, 0x01, + 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, + 0x0a, 0x10, 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, + 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, + 0x6f, 0x78, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, + 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, + 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, + 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, + 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, + 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, + 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, + 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, + 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, + 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, + 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, + 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -3145,117 +3640,127 @@ func file_resource_proto_rawDescGZIP() []byte { return file_resource_proto_rawDescData } -var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 36) +var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 40) var file_resource_proto_goTypes = []any{ - (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch - (WatchEvent_Type)(0), // 1: resource.WatchEvent.Type - (HealthCheckResponse_ServingStatus)(0), // 2: resource.HealthCheckResponse.ServingStatus - (PutBlobRequest_Method)(0), // 3: resource.PutBlobRequest.Method - (*ResourceKey)(nil), // 4: resource.ResourceKey - (*ResourceWrapper)(nil), // 5: resource.ResourceWrapper - (*ResourceMeta)(nil), // 6: resource.ResourceMeta - (*ErrorResult)(nil), // 7: resource.ErrorResult - (*ErrorDetails)(nil), // 8: resource.ErrorDetails - (*ErrorCause)(nil), // 9: resource.ErrorCause - (*CreateRequest)(nil), // 10: resource.CreateRequest - (*CreateResponse)(nil), // 11: resource.CreateResponse - (*UpdateRequest)(nil), // 12: resource.UpdateRequest - (*UpdateResponse)(nil), // 13: resource.UpdateResponse - (*DeleteRequest)(nil), // 14: resource.DeleteRequest - (*DeleteResponse)(nil), // 15: resource.DeleteResponse - (*ReadRequest)(nil), // 16: resource.ReadRequest - (*ReadResponse)(nil), // 17: resource.ReadResponse - (*Requirement)(nil), // 18: resource.Requirement - (*ListOptions)(nil), // 19: resource.ListOptions - (*ListRequest)(nil), // 20: resource.ListRequest - (*ListResponse)(nil), // 21: resource.ListResponse - (*WatchRequest)(nil), // 22: resource.WatchRequest - (*WatchEvent)(nil), // 23: resource.WatchEvent - (*SearchRequest)(nil), // 24: resource.SearchRequest - (*GroupBy)(nil), // 25: resource.GroupBy - (*Group)(nil), // 26: resource.Group - (*SearchResponse)(nil), // 27: resource.SearchResponse - (*HistoryRequest)(nil), // 28: resource.HistoryRequest - (*HistoryResponse)(nil), // 29: resource.HistoryResponse - (*OriginRequest)(nil), // 30: resource.OriginRequest - (*ResourceOriginInfo)(nil), // 31: resource.ResourceOriginInfo - (*OriginResponse)(nil), // 32: resource.OriginResponse - (*HealthCheckRequest)(nil), // 33: resource.HealthCheckRequest - (*HealthCheckResponse)(nil), // 34: resource.HealthCheckResponse - (*PutBlobRequest)(nil), // 35: resource.PutBlobRequest - (*PutBlobResponse)(nil), // 36: resource.PutBlobResponse - (*GetBlobRequest)(nil), // 37: resource.GetBlobRequest - (*GetBlobResponse)(nil), // 38: resource.GetBlobResponse - (*WatchEvent_Resource)(nil), // 39: resource.WatchEvent.Resource + (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch + (WatchEvent_Type)(0), // 1: resource.WatchEvent.Type + (HealthCheckResponse_ServingStatus)(0), // 2: resource.HealthCheckResponse.ServingStatus + (ResourceTableColumnDefinition_ColumnType)(0), // 3: resource.ResourceTableColumnDefinition.ColumnType + (PutBlobRequest_Method)(0), // 4: resource.PutBlobRequest.Method + (*ResourceKey)(nil), // 5: resource.ResourceKey + (*ResourceWrapper)(nil), // 6: resource.ResourceWrapper + (*ResourceMeta)(nil), // 7: resource.ResourceMeta + (*ErrorResult)(nil), // 8: resource.ErrorResult + (*ErrorDetails)(nil), // 9: resource.ErrorDetails + (*ErrorCause)(nil), // 10: resource.ErrorCause + (*CreateRequest)(nil), // 11: resource.CreateRequest + (*CreateResponse)(nil), // 12: resource.CreateResponse + (*UpdateRequest)(nil), // 13: resource.UpdateRequest + (*UpdateResponse)(nil), // 14: resource.UpdateResponse + (*DeleteRequest)(nil), // 15: resource.DeleteRequest + (*DeleteResponse)(nil), // 16: resource.DeleteResponse + (*ReadRequest)(nil), // 17: resource.ReadRequest + (*ReadResponse)(nil), // 18: resource.ReadResponse + (*Requirement)(nil), // 19: resource.Requirement + (*ListOptions)(nil), // 20: resource.ListOptions + (*ListRequest)(nil), // 21: resource.ListRequest + (*ListResponse)(nil), // 22: resource.ListResponse + (*WatchRequest)(nil), // 23: resource.WatchRequest + (*WatchEvent)(nil), // 24: resource.WatchEvent + (*SearchRequest)(nil), // 25: resource.SearchRequest + (*GroupBy)(nil), // 26: resource.GroupBy + (*Group)(nil), // 27: resource.Group + (*SearchResponse)(nil), // 28: resource.SearchResponse + (*HistoryRequest)(nil), // 29: resource.HistoryRequest + (*HistoryResponse)(nil), // 30: resource.HistoryResponse + (*OriginRequest)(nil), // 31: resource.OriginRequest + (*ResourceOriginInfo)(nil), // 32: resource.ResourceOriginInfo + (*OriginResponse)(nil), // 33: resource.OriginResponse + (*HealthCheckRequest)(nil), // 34: resource.HealthCheckRequest + (*HealthCheckResponse)(nil), // 35: resource.HealthCheckResponse + (*ResourceTable)(nil), // 36: resource.ResourceTable + (*ResourceTableColumnDefinition)(nil), // 37: resource.ResourceTableColumnDefinition + (*ResourceTableRow)(nil), // 38: resource.ResourceTableRow + (*PutBlobRequest)(nil), // 39: resource.PutBlobRequest + (*PutBlobResponse)(nil), // 40: resource.PutBlobResponse + (*GetBlobRequest)(nil), // 41: resource.GetBlobRequest + (*GetBlobResponse)(nil), // 42: resource.GetBlobResponse + (*WatchEvent_Resource)(nil), // 43: resource.WatchEvent.Resource + (*ResourceTableColumnDefinition_Properties)(nil), // 44: resource.ResourceTableColumnDefinition.Properties } var file_resource_proto_depIdxs = []int32{ - 8, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails - 9, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause - 4, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey - 7, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult - 4, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey - 7, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult - 4, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey - 7, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult - 4, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey - 7, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult - 4, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey - 18, // 11: resource.ListOptions.labels:type_name -> resource.Requirement - 18, // 12: resource.ListOptions.fields:type_name -> resource.Requirement + 9, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails + 10, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause + 5, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey + 8, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult + 5, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey + 8, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult + 5, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey + 8, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult + 5, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey + 8, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult + 5, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey + 19, // 11: resource.ListOptions.labels:type_name -> resource.Requirement + 19, // 12: resource.ListOptions.fields:type_name -> resource.Requirement 0, // 13: resource.ListRequest.version_match:type_name -> resource.ResourceVersionMatch - 19, // 14: resource.ListRequest.options:type_name -> resource.ListOptions - 5, // 15: resource.ListResponse.items:type_name -> resource.ResourceWrapper - 7, // 16: resource.ListResponse.error:type_name -> resource.ErrorResult - 19, // 17: resource.WatchRequest.options:type_name -> resource.ListOptions + 20, // 14: resource.ListRequest.options:type_name -> resource.ListOptions + 6, // 15: resource.ListResponse.items:type_name -> resource.ResourceWrapper + 8, // 16: resource.ListResponse.error:type_name -> resource.ErrorResult + 20, // 17: resource.WatchRequest.options:type_name -> resource.ListOptions 1, // 18: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type - 39, // 19: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource - 39, // 20: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource - 25, // 21: resource.SearchRequest.groupBy:type_name -> resource.GroupBy - 5, // 22: resource.SearchResponse.items:type_name -> resource.ResourceWrapper - 26, // 23: resource.SearchResponse.groups:type_name -> resource.Group - 4, // 24: resource.HistoryRequest.key:type_name -> resource.ResourceKey - 6, // 25: resource.HistoryResponse.items:type_name -> resource.ResourceMeta - 7, // 26: resource.HistoryResponse.error:type_name -> resource.ErrorResult - 4, // 27: resource.OriginRequest.key:type_name -> resource.ResourceKey - 4, // 28: resource.ResourceOriginInfo.key:type_name -> resource.ResourceKey - 31, // 29: resource.OriginResponse.items:type_name -> resource.ResourceOriginInfo - 7, // 30: resource.OriginResponse.error:type_name -> resource.ErrorResult + 43, // 19: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource + 43, // 20: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource + 26, // 21: resource.SearchRequest.groupBy:type_name -> resource.GroupBy + 6, // 22: resource.SearchResponse.items:type_name -> resource.ResourceWrapper + 27, // 23: resource.SearchResponse.groups:type_name -> resource.Group + 5, // 24: resource.HistoryRequest.key:type_name -> resource.ResourceKey + 7, // 25: resource.HistoryResponse.items:type_name -> resource.ResourceMeta + 8, // 26: resource.HistoryResponse.error:type_name -> resource.ErrorResult + 5, // 27: resource.OriginRequest.key:type_name -> resource.ResourceKey + 5, // 28: resource.ResourceOriginInfo.key:type_name -> resource.ResourceKey + 32, // 29: resource.OriginResponse.items:type_name -> resource.ResourceOriginInfo + 8, // 30: resource.OriginResponse.error:type_name -> resource.ErrorResult 2, // 31: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus - 4, // 32: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey - 3, // 33: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method - 7, // 34: resource.PutBlobResponse.error:type_name -> resource.ErrorResult - 4, // 35: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey - 7, // 36: resource.GetBlobResponse.error:type_name -> resource.ErrorResult - 16, // 37: resource.ResourceStore.Read:input_type -> resource.ReadRequest - 10, // 38: resource.ResourceStore.Create:input_type -> resource.CreateRequest - 12, // 39: resource.ResourceStore.Update:input_type -> resource.UpdateRequest - 14, // 40: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest - 20, // 41: resource.ResourceStore.List:input_type -> resource.ListRequest - 22, // 42: resource.ResourceStore.Watch:input_type -> resource.WatchRequest - 24, // 43: resource.ResourceIndex.Search:input_type -> resource.SearchRequest - 28, // 44: resource.ResourceIndex.History:input_type -> resource.HistoryRequest - 30, // 45: resource.ResourceIndex.Origin:input_type -> resource.OriginRequest - 35, // 46: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest - 37, // 47: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest - 33, // 48: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest - 17, // 49: resource.ResourceStore.Read:output_type -> resource.ReadResponse - 11, // 50: resource.ResourceStore.Create:output_type -> resource.CreateResponse - 13, // 51: resource.ResourceStore.Update:output_type -> resource.UpdateResponse - 15, // 52: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse - 21, // 53: resource.ResourceStore.List:output_type -> resource.ListResponse - 23, // 54: resource.ResourceStore.Watch:output_type -> resource.WatchEvent - 27, // 55: resource.ResourceIndex.Search:output_type -> resource.SearchResponse - 29, // 56: resource.ResourceIndex.History:output_type -> resource.HistoryResponse - 32, // 57: resource.ResourceIndex.Origin:output_type -> resource.OriginResponse - 36, // 58: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse - 38, // 59: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse - 34, // 60: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse - 49, // [49:61] is the sub-list for method output_type - 37, // [37:49] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 37, // 32: resource.ResourceTable.columns:type_name -> resource.ResourceTableColumnDefinition + 38, // 33: resource.ResourceTable.rows:type_name -> resource.ResourceTableRow + 3, // 34: resource.ResourceTableColumnDefinition.type:type_name -> resource.ResourceTableColumnDefinition.ColumnType + 44, // 35: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties + 5, // 36: resource.ResourceTableRow.key:type_name -> resource.ResourceKey + 5, // 37: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey + 4, // 38: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method + 8, // 39: resource.PutBlobResponse.error:type_name -> resource.ErrorResult + 5, // 40: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey + 8, // 41: resource.GetBlobResponse.error:type_name -> resource.ErrorResult + 17, // 42: resource.ResourceStore.Read:input_type -> resource.ReadRequest + 11, // 43: resource.ResourceStore.Create:input_type -> resource.CreateRequest + 13, // 44: resource.ResourceStore.Update:input_type -> resource.UpdateRequest + 15, // 45: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest + 21, // 46: resource.ResourceStore.List:input_type -> resource.ListRequest + 23, // 47: resource.ResourceStore.Watch:input_type -> resource.WatchRequest + 25, // 48: resource.ResourceIndex.Search:input_type -> resource.SearchRequest + 29, // 49: resource.ResourceIndex.History:input_type -> resource.HistoryRequest + 31, // 50: resource.ResourceIndex.Origin:input_type -> resource.OriginRequest + 39, // 51: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest + 41, // 52: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest + 34, // 53: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest + 18, // 54: resource.ResourceStore.Read:output_type -> resource.ReadResponse + 12, // 55: resource.ResourceStore.Create:output_type -> resource.CreateResponse + 14, // 56: resource.ResourceStore.Update:output_type -> resource.UpdateResponse + 16, // 57: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse + 22, // 58: resource.ResourceStore.List:output_type -> resource.ListResponse + 24, // 59: resource.ResourceStore.Watch:output_type -> resource.WatchEvent + 28, // 60: resource.ResourceIndex.Search:output_type -> resource.SearchResponse + 30, // 61: resource.ResourceIndex.History:output_type -> resource.HistoryResponse + 33, // 62: resource.ResourceIndex.Origin:output_type -> resource.OriginResponse + 40, // 63: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse + 42, // 64: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse + 35, // 65: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse + 54, // [54:66] is the sub-list for method output_type + 42, // [42:54] is the sub-list for method input_type + 42, // [42:42] is the sub-list for extension type_name + 42, // [42:42] is the sub-list for extension extendee + 0, // [0:42] is the sub-list for field type_name } func init() { file_resource_proto_init() } @@ -3268,8 +3773,8 @@ func file_resource_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_resource_proto_rawDesc, - NumEnums: 4, - NumMessages: 36, + NumEnums: 5, + NumMessages: 40, NumExtensions: 0, NumServices: 4, }, diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index d403865d03c..9c89f86ef1a 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -108,24 +108,24 @@ message ErrorDetails { } message ErrorCause { - // A machine-readable description of the cause of the error. If this value is - // empty there is no information available. - string reason = 1; - // A human-readable description of the cause of the error. This field may be - // presented as-is to a reader. - // +optional - string message = 2; - // The field of the resource that has caused this error, as named by its JSON - // serialization. May include dot and postfix notation for nested attributes. - // Arrays are zero-indexed. Fields may appear more than once in an array of - // causes due to fields having multiple errors. - // Optional. - // - // Examples: - // "name" - the field "name" on the current resource - // "items[0].name" - the field "name" on the first array entry in "items" - // +optional - string field = 3; + // A machine-readable description of the cause of the error. If this value is + // empty there is no information available. + string reason = 1; + // A human-readable description of the cause of the error. This field may be + // presented as-is to a reader. + // +optional + string message = 2; + // The field of the resource that has caused this error, as named by its JSON + // serialization. May include dot and postfix notation for nested attributes. + // Arrays are zero-indexed. Fields may appear more than once in an array of + // causes due to fields having multiple errors. + // Optional. + // + // Examples: + // "name" - the field "name" on the current resource + // "items[0].name" - the field "name" on the first array entry in "items" + // +optional + string field = 3; } // ---------------------------------- @@ -448,6 +448,118 @@ message HealthCheckResponse { ServingStatus status = 1; } +// ResourceTable is a protobuf variation of the kubernetes Table object. +// This format allows specifying a flexible set of columns related to a given resource +message ResourceTable { + // Columns describes each column in the returned items array. The number of cells per row + // will always match the number of column definitions. + repeated ResourceTableColumnDefinition columns = 1; + + // rows is the list of items in the table. + repeated ResourceTableRow rows = 2; + + // When more results exist, pass this in the next request + string next_page_token = 3; + + // ResourceVersion of the list response + // +optional + int64 resource_version = 4; + + // remainingItemCount is the number of subsequent items in the list which are not included in this + // list response. If the list request contained label or field selectors, then the number of + // remaining items is unknown and the field will be left unset and omitted during serialization. + // If the list is complete (either because it is not chunking or because this is the last chunk), + // then there are no more remaining items and this field will be left unset and omitted during + // serialization. + // + // The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + // should not rely on the remainingItemCount to be set or to be exact. + // +optional + int64 remaining_item_count = 5; +} + +// TableColumnDefinition contains information about a column returned in the Table. +message ResourceTableColumnDefinition { + // See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. + // When converted to a k8s Table, this will become two fields: type and format + enum ColumnType { + UNKNOWN_TYPE = 0; + STRING = 1; + BOOLEAN = 2; + INT32 = 3; + INT64 = 4; + FLOAT = 5; + DOUBLE = 6; + DATE = 7; + DATE_TIME = 8; + BINARY = 9; + OBJECT = 10; // map[string]any + } + + // These values are not part of standard k8s format + // however these are useful when indexing and analyzing results + message Properties { + // All values in this columns should be unique + bool unique_values = 1; + + // The string value is free text; using text analyzers is appropriate + bool free_text = 2; + + // The value(s) are reasonable to use for search refinement + // When indexing, these values would be good to add to an index + bool filterable = 3; + + // When true, every value should exist + // not_null with a nil default_value should be an error + bool not_null = 4; + + // When missing, this value can be used + bytes default_value = 5; + } + + // name is a human readable name for the column. + string name = 1; + + // Defines the column type. In k8s, this will resolve into both the type and format fields + ColumnType type = 2; + + // The value is an arry of given type + bool is_array = 3; + + // description is a human readable description of this column. + string description = 4; + + // Properties about this column (helpful for indexing and search) + Properties properties = 5; + + // priority is an integer defining the relative importance of this column compared to others. Lower + // numbers are considered higher priority. Columns that may be omitted in limited space scenarios + // should be given a higher priority. + int32 priority = 6; +} + +// TableRow is an individual row in a table. +message ResourceTableRow { + // The resource referenced by this row + ResourceKey key = 1; + + // The resource version for the given values + int64 resource_version = 2; + + // Cells will be as wide as the column definitions array + // Numeric values will be encoded using big endian bytes + // All arrays will be JSON encoded + repeated bytes cells = 3; + + // This field may contains the additional information about each object based on the request. + // The value will be at least a partial object metadata, and perhaps the full object metadata. + // When this value exists, it should include both the key and the resource_version otherwise + // they may be lost in the conversion to k8s resource + // +optional + bytes object = 4; +} + + //---------------------------- // Blob Support //---------------------------- diff --git a/pkg/storage/unified/resource/table.go b/pkg/storage/unified/resource/table.go new file mode 100644 index 00000000000..a0f6574c07e --- /dev/null +++ b/pkg/storage/unified/resource/table.go @@ -0,0 +1,549 @@ +package resource + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + reflect "reflect" + "strconv" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" +) + +// Convert the the protobuf model into k8s (will decode each value) +func (x *ResourceTable) ToK8s() (metav1.Table, error) { + table := metav1.Table{ + ListMeta: metav1.ListMeta{ + Continue: x.NextPageToken, + }, + } + if x.RemainingItemCount > 0 { + table.RemainingItemCount = &x.RemainingItemCount + } + if x.ResourceVersion > 0 { + table.ResourceVersion = strconv.FormatInt(x.ResourceVersion, 10) + } + + columnCount := len(x.Columns) + columns := make([]resourceTableColumn, columnCount) + table.ColumnDefinitions = make([]metav1.TableColumnDefinition, columnCount) + for i, c := range x.Columns { + col, err := newResourceTableColumn(c, i) + if err != nil { + return table, err + } + columns[i] = *col + table.ColumnDefinitions[i] = metav1.TableColumnDefinition{ + Name: c.Name, + Description: c.Description, + Priority: c.Priority, + Type: col.OpenAPIType, + Format: col.OpenAPIFormat, + } + } + + var err error + table.Rows = make([]metav1.TableRow, len(x.Rows)) + for i, r := range x.Rows { + row := metav1.TableRow{ + Cells: make([]interface{}, len(r.Cells)), + } + if len(r.Cells) != columnCount { + return table, fmt.Errorf("invalid cells size (have=%d, expect=%d)", len(r.Cells), columnCount) + } + + for j, v := range r.Cells { + row.Cells[j], err = columns[j].Decode(v) + if err != nil { + col := columns[j] + return table, fmt.Errorf("error decoding (row=%d, column=%d, type=%s) %w", i, j, col.def.Type.String(), err) + } + } + + // The raw object value + if r.Object != nil { + row.Object = runtime.RawExtension{ + Raw: r.Object, + } + } else if r.Key != nil { + obj := &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.Key.Name, + Namespace: r.Key.Namespace, + }, + } + if r.ResourceVersion > 0 { + obj.ResourceVersion = strconv.FormatInt(r.ResourceVersion, 10) + } + row.Object.Object = obj + row.Object.Raw, err = json.Marshal(obj) + if err != nil { + return table, err + } + } + table.Rows[i] = row + } + return table, err +} + +type TableBuilder struct { + ResourceTable + + lookup map[string]*resourceTableColumn + + // Just keep track of it + hasDuplicateNames bool +} + +func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, error) { + table := &TableBuilder{ + ResourceTable: ResourceTable{ + Columns: cols, + }, + + lookup: make(map[string]*resourceTableColumn, len(cols)), + } + var err error + for i, v := range cols { + if table.lookup[v.Name] != nil { + table.hasDuplicateNames = true + continue + } + table.lookup[v.Name], err = newResourceTableColumn(v, i) + if err != nil { + return nil, err + } + } + return table, err +} + +func (x *TableBuilder) AddRow(key *ResourceKey, rv int64, vals map[string]any) error { + row := &ResourceTableRow{ + Key: key, + ResourceVersion: rv, + Cells: make([][]byte, len(x.Columns)), + } + + for k, v := range vals { + column, ok := x.lookup[k] + if !ok { + return fmt.Errorf("unknown column: %s", k) + } + b, err := column.Encode(v) + if err != nil { + return err + } + row.Cells[column.index] = b + } + + x.Rows = append(x.Rows, row) + return nil +} + +type resourceTableColumn struct { + def *ResourceTableColumnDefinition + index int + + // Used for array indexing + reader func(iter *jsoniter.Iterator) (any, error) + writer func(v any, stream *jsoniter.Stream) error + + OpenAPIType string + OpenAPIFormat string +} + +// nolint:gocyclo +func newResourceTableColumn(def *ResourceTableColumnDefinition, index int) (*resourceTableColumn, error) { + col := &resourceTableColumn{def: def, index: index} + + // Initially ignore the array property, we wil wrap that at the end + switch def.Type { + case ResourceTableColumnDefinition_UNKNOWN_TYPE: + return nil, fmt.Errorf("unknown column type") + + case ResourceTableColumnDefinition_STRING: + col.OpenAPIType = "string" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadString() + } + + case ResourceTableColumnDefinition_BOOLEAN: + col.OpenAPIType = "boolean" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadBool() + } + + case ResourceTableColumnDefinition_INT32: + col.OpenAPIType = "number" + col.OpenAPIFormat = "int32" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadInt32() + } + + case ResourceTableColumnDefinition_INT64: + col.OpenAPIType = "number" + col.OpenAPIFormat = "int64" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadInt64() + } + + case ResourceTableColumnDefinition_DOUBLE: + col.OpenAPIType = "number" + col.OpenAPIFormat = "double" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadFloat64() + } + + case ResourceTableColumnDefinition_FLOAT: + col.OpenAPIType = "number" + col.OpenAPIFormat = "float" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadFloat32() + } + + // Encode everything we can -- the lower conversion can happen later? + case ResourceTableColumnDefinition_DATE, ResourceTableColumnDefinition_DATE_TIME: + col.OpenAPIType = "string" + col.OpenAPIFormat = "date" + if def.Type == ResourceTableColumnDefinition_DATE_TIME { + col.OpenAPIFormat = "date_time" + } + col.writer = func(v any, stream *jsoniter.Stream) error { + var t time.Time + + switch typed := v.(type) { + case time.Time: + t = typed + case *time.Time: + t = *typed + case int64: + t = time.UnixMilli(typed) + default: + return fmt.Errorf("unsupported date conversion (%t)", v) + } + + // encode as millis has fastest parsing + stream.WriteInt64(t.UnixMilli()) + return stream.Error + } + col.reader = func(iter *jsoniter.Iterator) (any, error) { + nxt, err := iter.WhatIsNext() + if err != nil { + return nil, err + } + switch nxt { + case jsoniter.NumberValue: + ts, err := iter.ReadInt64() + if err != nil { + return nil, err + } + return time.UnixMilli(ts).UTC(), nil + + default: + return nil, fmt.Errorf("unexpected JSON for date: %+v", nxt) + } + } + + case ResourceTableColumnDefinition_BINARY: + col.OpenAPIType = "binary" + col.writer = func(v any, stream *jsoniter.Stream) error { + b, ok := v.([]byte) + if !ok { + return fmt.Errorf("unexpected binary type, found: %t", v) + } + str := base64.StdEncoding.EncodeToString(b) + stream.WriteString(str) + return stream.Error + } + col.reader = func(iter *jsoniter.Iterator) (any, error) { + str, err := iter.ReadString() + if err != nil { + return nil, err + } + return base64.StdEncoding.DecodeString(str) + } + + case ResourceTableColumnDefinition_OBJECT: + col.OpenAPIType = "string" + col.OpenAPIFormat = "json" + + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.Read() + } + } + + return col, nil +} + +func (x *resourceTableColumn) IsNotNil() bool { + if x.def.Properties != nil { + return x.def.Properties.NotNull + } + return false +} + +// nolint:gocyclo +func (x *resourceTableColumn) Encode(v any) ([]byte, error) { + if v == nil { + if x.IsNotNil() { + return nil, fmt.Errorf("expecting non-null value") + } + return nil, nil // no types to write + } + + // Arrays will always use JSON formatting + if !x.def.IsArray { + switch x.def.Type { + case ResourceTableColumnDefinition_STRING: + { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("expecting a string field") + } + return []byte(s), nil + } + case ResourceTableColumnDefinition_BINARY: + { + s, ok := v.([]byte) + if !ok { + return nil, fmt.Errorf("expecting a byte array") + } + return s, nil + } + case ResourceTableColumnDefinition_BOOLEAN: + { + b, ok := v.(bool) + if !ok { + switch typed := v.(type) { + case *bool: + b = *typed + case int: + b = typed != 0 + case int32: + b = typed != 0 + case int64: + b = typed != 0 + default: + return nil, fmt.Errorf("unexpected input for double field: %t", v) + } + } + if b { + return []byte{1}, nil + } + return []byte{0}, nil + } + case ResourceTableColumnDefinition_DATE_TIME, ResourceTableColumnDefinition_DATE: + { + f, ok := v.(time.Time) + if !ok { + switch typed := v.(type) { + case *time.Time: + f = *typed + case metav1.Time: + f = typed.Time + case *metav1.Time: + f = typed.Time + case int64: + f = time.UnixMilli(typed) + default: + return nil, fmt.Errorf("unexpected input for time field: %t", v) + } + } + ts := f.UnixMilli() + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, ts) + return buf.Bytes(), err + } + case ResourceTableColumnDefinition_DOUBLE: + { + f, ok := v.(float64) + if !ok { + switch typed := v.(type) { + case int: + f = float64(typed) + case int64: + f = float64(typed) + case float32: + f = float64(typed) + case uint64: + f = float64(typed) + case uint: + f = float64(typed) + default: + return nil, fmt.Errorf("unexpected input for double field: %t", v) + } + } + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, f) + return buf.Bytes(), err + } + case ResourceTableColumnDefinition_INT64: + { + f, ok := v.(int64) + if !ok { + switch typed := v.(type) { + case int: + f = int64(typed) + case int32: + f = int64(typed) + case float32: + f = int64(typed) + case uint64: + f = int64(typed) + case uint: + f = int64(typed) + default: + return nil, fmt.Errorf("unexpected input for int64 field: %t", v) + } + } + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, f) + return buf.Bytes(), err + } + default: + // use JSON encoding below + } + } + + buff := bytes.NewBuffer(make([]byte, 0, 128)) + cfg := jsoniter.ConfigCompatibleWithStandardLibrary + stream := cfg.BorrowStream(buff) + defer cfg.ReturnStream(stream) + var err error + + writer := func(v any) error { + if v == nil { + stream.WriteNil() // only happens in an array + } else if x.writer != nil { + return x.writer(v, stream) + } else { + stream.WriteVal(v) + } + return stream.Error + } + + if x.def.IsArray { + stream.WriteArrayStart() + + switch reflect.TypeOf(v).Kind() { + case reflect.Slice, reflect.Array: + s := reflect.ValueOf(v) + for i := 0; i < s.Len(); i++ { + if i > 0 { + stream.WriteMore() + } + sub := s.Index(i).Interface() + err = writer(sub) + if err != nil { + return nil, err + } + } + default: + // single value? just write it and we will see? + err = writer(v) + if err != nil { + return nil, err + } + } + + stream.WriteArrayEnd() + } else { + err = writer(v) + } + if err != nil { + return nil, err + } + if stream.Error != nil { + return nil, stream.Error + } + + err = stream.Flush() + if err != nil { + return nil, err + } + return json.RawMessage(buff.Bytes()), nil +} + +// nolint:gocyclo +func (x *resourceTableColumn) Decode(buff []byte) (any, error) { + if len(buff) == 0 { + return nil, nil + } + if !x.def.IsArray { + switch x.def.Type { + case ResourceTableColumnDefinition_STRING: + return string(buff), nil + case ResourceTableColumnDefinition_BINARY: + return buff, nil + case ResourceTableColumnDefinition_BOOLEAN: + if len(buff) == 1 { + return buff[0] != 0, nil + } + case ResourceTableColumnDefinition_DOUBLE: + { + var f float64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return f, nil + } + } + case ResourceTableColumnDefinition_INT64: + { + var f int64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return f, nil + } + } + case ResourceTableColumnDefinition_DATE_TIME, ResourceTableColumnDefinition_DATE: + { + var f int64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return time.UnixMilli(f).UTC(), nil + } + } + default: + // use JSON decoding below + } + } + + iter, err := jsoniter.ParseBytes(jsoniter.ConfigDefault, buff) + if err != nil { + return nil, err + } + + if x.def.IsArray { + vals := []any{} // it may have nulls + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return nil, err + } + v, err := x.reader(iter) + //nolint:errorlint + if err != nil && err != io.EOF { // EOF is normal when jsoniter is done + return nil, err + } + vals = append(vals, v) + } + + return vals, iter.ReadError() + } + + v, err := x.reader(iter) + //nolint:errorlint + if err == io.EOF { + err = nil + } else if err != nil { + return nil, err + } + return v, err +} diff --git a/pkg/storage/unified/resource/table_test.go b/pkg/storage/unified/resource/table_test.go new file mode 100644 index 00000000000..17c110ea18f --- /dev/null +++ b/pkg/storage/unified/resource/table_test.go @@ -0,0 +1,343 @@ +package resource + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// AssertTableSnapshot will match a ResourceTable vs the saved value +func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) { + t.Helper() + + k8sTable, err := table.ToK8s() + require.NoError(t, err, "unable to create table response", path) + actual, err := json.MarshalIndent(k8sTable, "", " ") + require.NoError(t, err, "unable to write table json", path) + + // Safe to disable, this is a test. + // nolint:gosec + expected, err := os.ReadFile(path) + if err != nil || len(expected) < 1 { + assert.Fail(t, "missing file") + } else if assert.JSONEq(t, string(expected), string(actual)) { + return // everything is OK + } + + // Write the snapshot + // Safe to disable, this is a test. + // nolint:gosec + err = os.WriteFile(path, actual, 0600) + require.NoError(t, err) + fmt.Printf("Updated table snapshot: %s\n", path) +} + +func TestTableFormat(t *testing.T) { + columns := []*ResourceTableColumnDefinition{ + { + Name: "title", + Type: ResourceTableColumnDefinition_STRING, + }, + { + Name: "stats.count", + Type: ResourceTableColumnDefinition_INT64, + }, + { + Name: "number", + Type: ResourceTableColumnDefinition_DOUBLE, + + Description: "float64 value", + }, + { + Name: "tags", + Type: ResourceTableColumnDefinition_STRING, + IsArray: true, + }, + } + + var err error + builder, err := NewTableBuilder(columns) + require.NoError(t, err) + + err = builder.AddRow(&ResourceKey{ + Namespace: "default", + Group: "ggg", + Resource: "xyz", // does not have a home in table! + Name: "aaa", + }, 10, map[string]any{ + "title": "AAA", + "number": 12345, + "tags": "one", // becomes an array + }) + require.NoError(t, err) + + err = builder.AddRow(&ResourceKey{ + Namespace: "default", + Group: "ggg", + Resource: "xyz", // does not have a home in table! + Name: "bbb", + }, 10, map[string]any{ + "title": "BBB", + "stats.count": 12345, + "tags": []string{"one", "two"}, // becomes an array + }) + require.NoError(t, err) + + // Check the snapshot + AssertTableSnapshot(t, filepath.Join("testdata", "simple-table.json"), &builder.ResourceTable) +} + +func TestColumnEncoding(t *testing.T) { + tests := []struct { + // The table definition + def *ResourceTableColumnDefinition + + // Passed to the encode function + input any + + // Expected error from input + input_err error + + // Skip the encode step + raw []byte + + // Expected output from decode + output any + + // Expected error from decode + output_err error + }{ + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_STRING, + }, + input: "aaa", // expects output to match + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_STRING, + IsArray: true, + }, + input: "bbb", + output: []any{"bbb"}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT64, + }, + input: 12345, + output: int64(12345), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT64, + IsArray: true, + }, + input: 12345, + output: []any{int64(12345)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DOUBLE, + }, + input: 12345, + output: float64(12345), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DOUBLE, + IsArray: true, + }, + input: 12345, + output: []any{float64(12345)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BOOLEAN, + }, + input: true, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BOOLEAN, + IsArray: true, + }, + input: []any{true, false, true}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_FLOAT, + }, + input: 23.4, + output: float32(23.4), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_FLOAT, + IsArray: true, + }, + input: 23.4, + output: []any{float32(23.4)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT32, + }, + input: 56, + output: int32(56), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT32, + IsArray: true, + }, + input: 56, + output: []any{int32(56)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE_TIME, + }, + input: time.UnixMilli(946674000000).UTC(), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE_TIME, + IsArray: true, + }, + input: time.UnixMilli(946674000000).UTC(), + output: []any{ + time.UnixMilli(946674000000).UTC(), + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE, + }, + input: time.UnixMilli(946674000000).UTC(), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE, + IsArray: true, + }, + input: time.UnixMilli(946674000000).UTC(), + output: []any{ + time.UnixMilli(946674000000).UTC(), + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BINARY, + }, + input: []byte{1, 2, 3, 4}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BINARY, + IsArray: true, + }, + input: []any{ + []byte{1, 2, 3, 4}, + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_OBJECT, + }, + input: map[string]any{ + "hello": "world", + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_OBJECT, + IsArray: true, + }, + input: map[string]any{ + "hello": "world", + }, + output: []any{ + map[string]any{ + "hello": "world", + }, + }, + }, + } + + // Keep track of the types that have tests + testedTypes := make(map[ResourceTableColumnDefinition_ColumnType]bool) + testedArrays := make(map[ResourceTableColumnDefinition_ColumnType]bool) + for _, test := range tests { + var sb strings.Builder + if test.def.IsArray { + sb.WriteString("[]") + testedArrays[test.def.Type] = true + } else { + testedTypes[test.def.Type] = true + } + sb.WriteString(test.def.Type.String()) + if test.def.Name != "" { + sb.WriteString("(") + sb.WriteString(test.def.Name) + sb.WriteString(")") + } + sb.WriteString("=") + sb.WriteString(fmt.Sprintf("%v", test.input)) + + t.Run(sb.String(), func(t *testing.T) { + t.Parallel() + + col, err := newResourceTableColumn(test.def, 0) + require.NoError(t, err) + + buff := test.raw + if buff == nil { + buff, err = col.Encode(test.input) + if test.input_err != nil { + require.Equal(t, test.input_err, err) + } else { + require.NoError(t, err) + } + } + + out, err := col.Decode(buff) + if test.output_err != nil { + require.Equal(t, test.output_err, err) + } else { + require.NoError(t, err) + } + + if test.output != nil { + require.Equal(t, test.output, out) + } else { + require.Equal(t, test.input, out) + } + }) + } + + t.Run("ensure type coverage", func(t *testing.T) { + missingTypes := []string{} + missingArrays := []string{} + + // Make sure we have at least one test for each type + for i := ResourceTableColumnDefinition_STRING; i <= ResourceTableColumnDefinition_OBJECT; i++ { + if !testedTypes[i] { + missingTypes = append(missingTypes, i.String()) + } + if !testedArrays[i] { + missingArrays = append(missingArrays, i.String()) + } + } + + require.Empty(t, missingTypes, "missing tests for types") + require.Empty(t, missingArrays, "missing array tests for types") + }) +} diff --git a/pkg/storage/unified/resource/testdata/simple-table.json b/pkg/storage/unified/resource/testdata/simple-table.json new file mode 100644 index 00000000000..d95379968b7 --- /dev/null +++ b/pkg/storage/unified/resource/testdata/simple-table.json @@ -0,0 +1,72 @@ +{ + "metadata": {}, + "columnDefinitions": [ + { + "name": "title", + "type": "string", + "format": "", + "description": "", + "priority": 0 + }, + { + "name": "stats.count", + "type": "number", + "format": "int64", + "description": "", + "priority": 0 + }, + { + "name": "number", + "type": "number", + "format": "double", + "description": "float64 value", + "priority": 0 + }, + { + "name": "tags", + "type": "string", + "format": "", + "description": "", + "priority": 0 + } + ], + "rows": [ + { + "cells": [ + "AAA", + null, + 12345, + [ + "one" + ] + ], + "object": { + "metadata": { + "name": "aaa", + "namespace": "default", + "resourceVersion": "10", + "creationTimestamp": null + } + } + }, + { + "cells": [ + "BBB", + 12345, + null, + [ + "one", + "two" + ] + ], + "object": { + "metadata": { + "name": "bbb", + "namespace": "default", + "resourceVersion": "10", + "creationTimestamp": null + } + } + } + ] +} \ No newline at end of file From efb420dd78fc4f34b90db42e1787c1d7c8eec8bf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:43:36 +0000 Subject: [PATCH 18/60] Update dependency @formatjs/intl-durationformat to v0.6.4 (#96662) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index f80c6fe562b..b6c0d4737cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2934,14 +2934,14 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.2.3": - version: 2.2.3 - resolution: "@formatjs/ecma402-abstract@npm:2.2.3" +"@formatjs/ecma402-abstract@npm:2.2.4": + version: 2.2.4 + resolution: "@formatjs/ecma402-abstract@npm:2.2.4" dependencies: "@formatjs/fast-memoize": "npm:2.2.3" - "@formatjs/intl-localematcher": "npm:0.5.7" + "@formatjs/intl-localematcher": "npm:0.5.8" tslib: "npm:2" - checksum: 10/d39e9f0d36c296a635f52aa35e07a67b6aa90383a30a046a0508e5d730676399fd0e67188eff463fe2a4d5febc9f567af45788fdf881e070910be7eb9294dd8c + checksum: 10/f7ab8be1e93f417a4a62c7be645618fec5652d412e55beffeeeada2b74660ad86b1876e3617ef0725f1454b193255dbb37ae779d8bb17ea71643e611e7edab02 languageName: node linkType: hard @@ -2985,13 +2985,13 @@ __metadata: linkType: hard "@formatjs/intl-durationformat@npm:^0.6.0": - version: 0.6.3 - resolution: "@formatjs/intl-durationformat@npm:0.6.3" + version: 0.6.4 + resolution: "@formatjs/intl-durationformat@npm:0.6.4" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.3" - "@formatjs/intl-localematcher": "npm:0.5.7" + "@formatjs/ecma402-abstract": "npm:2.2.4" + "@formatjs/intl-localematcher": "npm:0.5.8" tslib: "npm:2" - checksum: 10/1adc77b06e2f5791aef526cc3041de339cf99bc7e68cce81490066e0b1b0124d5cbe03d9f28b6a46bdd9ba22a0363315ccb20b9d1859ee0bcea7f79e9365844a + checksum: 10/aa7c045e94322f0a5723584e79dd1395b3c08e5d1d79432748ae98c0b0c1727059bd1aa5c04090df916ff1aea768336184a2d09ecfd2ece2fb090c472bbd8250 languageName: node linkType: hard @@ -3004,12 +3004,12 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.5.7": - version: 0.5.7 - resolution: "@formatjs/intl-localematcher@npm:0.5.7" +"@formatjs/intl-localematcher@npm:0.5.8": + version: 0.5.8 + resolution: "@formatjs/intl-localematcher@npm:0.5.8" dependencies: tslib: "npm:2" - checksum: 10/52201f12212e7e9cba1a4f99020da587b13e44e06e03c4ccd4e5ac0829b411e73dfe0904a9039ef81eeabeea04ed8cfae9e727e6791acd0230745b7bd3ad059e + checksum: 10/760fa292762d68904c133faa5d2900167fac03ee825e04e005438ae6e979facccbb4d12bba04906193feafd705348f191e4c3079c7bf54e0b66956a1199ad1df languageName: node linkType: hard From 4cc28c76fb016a4cb8edef21bf9f022c45259c32 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 19 Nov 2024 14:53:45 +0000 Subject: [PATCH 19/60] GrafanaUI: Size AutoSizeInput correctly when used with suffix/prefix (#96575) * GrafanaUI: Size AutoSizeInput correctly when used with suffix/prefix * comment * reword comment --- .../src/components/Input/AutoSizeInput.tsx | 61 ++++++++++--------- .../components/Input/AutoSizeInputContext.ts | 6 ++ .../grafana-ui/src/components/Input/Input.tsx | 37 ++++++++--- 3 files changed, 68 insertions(+), 36 deletions(-) create mode 100644 packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx index b6899a4caa3..9e7b275c120 100644 --- a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx +++ b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { measureText } from '../../utils/measureText'; +import { AutoSizeInputContext } from './AutoSizeInputContext'; import { Input, Props as InputProps } from './Input'; export interface Props extends InputProps { @@ -44,34 +45,38 @@ export const AutoSizeInput = React.forwardRef((props, r }, [placeholder, value, minWidth, maxWidth]); return ( - { - if (onChange) { - onChange(event); - } - setValue(event.currentTarget.value); - }} - width={inputWidth} - onBlur={(event) => { - if (onBlur) { - onBlur(event); - } else if (onCommitChange) { - onCommitChange(event); - } - }} - onKeyDown={(event) => { - if (onKeyDown) { - onKeyDown(event); - } else if (event.key === 'Enter' && onCommitChange) { - onCommitChange(event); - } - }} - data-testid={'autosize-input'} - /> + // Used to tell Input to increase the width properly of the input to fit the text. + // See comment in Input.tsx for more details + + { + if (onChange) { + onChange(event); + } + setValue(event.currentTarget.value); + }} + onBlur={(event) => { + if (onBlur) { + onBlur(event); + } else if (onCommitChange) { + onCommitChange(event); + } + }} + onKeyDown={(event) => { + if (onKeyDown) { + onKeyDown(event); + } else if (event.key === 'Enter' && onCommitChange) { + onCommitChange(event); + } + }} + width={inputWidth} + data-testid="autosize-input" + /> + ); }); diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts b/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts new file mode 100644 index 00000000000..328e382895b --- /dev/null +++ b/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts @@ -0,0 +1,6 @@ +import React from 'react'; + +// Used to tell Input to increase the width properly of the input to fit the text. +// See comment in Input.tsx for more details +export const AutoSizeInputContext = React.createContext(false); +AutoSizeInputContext.displayName = 'AutoSizeInputContext'; diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 8bd92e180d8..1240569cb57 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { forwardRef, HTMLProps, ReactNode } from 'react'; +import { forwardRef, HTMLProps, ReactNode, useContext } from 'react'; import useMeasure from 'react-use/lib/useMeasure'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,6 +8,8 @@ import { stylesFactory, useTheme2 } from '../../themes'; import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles'; import { Spinner } from '../Spinner/Spinner'; +import { AutoSizeInputContext } from './AutoSizeInputContext'; + export interface Props extends Omit, 'prefix' | 'size'> { /** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/ width?: number; @@ -32,7 +34,17 @@ interface StyleDeps { } export const Input = forwardRef((props, ref) => { - const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, width = 0, ...restProps } = props; + const { + className, + addonAfter, + addonBefore, + prefix, + suffix: suffixProp, + invalid, + loading, + width = 0, + ...restProps + } = props; /** * Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input * when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)). @@ -41,13 +53,24 @@ export const Input = forwardRef((props, ref) => { const [prefixRef, prefixRect] = useMeasure(); const [suffixRef, suffixRect] = useMeasure(); + // Yes, this is gross - When Input is being wrapped by AutoSizeInput, add the suffix/prefix width to the overall width + // so the text content is not clipped. The intention is to make all the input's text appear without overflow/clipping, + // which isn't normally how width is used in this component. + // This behaviour is not controlled via a prop so we can limit API surface, and remove this as a 'breaking change' later + // if a better solution is found. + const isInAutoSizeInput = useContext(AutoSizeInputContext); + const accessoriesWidth = (prefixRect.width || 0) + (suffixRect.width || 0); + const finalWidth = isInAutoSizeInput && width ? width + accessoriesWidth / 8 : width; + const theme = useTheme2(); - const styles = getInputStyles({ theme, invalid: !!invalid, width }); + + const styles = getInputStyles({ theme, invalid: !!invalid, width: finalWidth }); + + const suffix = suffixProp || (loading && ); return ( -
+
{!!addonBefore &&
{addonBefore}
} -
{prefix && (
@@ -65,14 +88,12 @@ export const Input = forwardRef((props, ref) => { }} /> - {(suffix || loading) && ( + {suffix && (
- {loading && } {suffix}
)}
- {!!addonAfter &&
{addonAfter}
}
); From 32de1a00d3c05cd0a5327fea45eb67928b49add1 Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 19 Nov 2024 09:06:16 -0600 Subject: [PATCH 20/60] Correlation: Fix loki dataplane field change (#94088) * Fix loki dataplane field change * Add test for dataplane * Reset feature toggle in test --- .../app/features/correlations/utils.test.ts | 46 +++++++++++++++---- public/app/features/correlations/utils.ts | 26 ++++++++++- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/public/app/features/correlations/utils.test.ts b/public/app/features/correlations/utils.test.ts index a42e6fd9688..f182b3c85c3 100644 --- a/public/app/features/correlations/utils.test.ts +++ b/public/app/features/correlations/utils.test.ts @@ -1,15 +1,17 @@ -import { DataFrame, DataSourceInstanceSettings, FieldType, toDataFrame } from '@grafana/data'; +import { DataFrame, DataFrameType, DataSourceInstanceSettings, FieldType, toDataFrame } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { CorrelationData } from './useCorrelations'; import { attachCorrelationsToDataFrames } from './utils'; describe('correlations utils', () => { it('attaches correlations defined in the configuration', () => { + config.featureToggles.lokiLogsDataplane = false; const { testDataFrames, correlations, refIdMap, prometheus, elastic } = setup(); attachCorrelationsToDataFrames(testDataFrames, correlations, refIdMap); - // Loki line (no links) - expect(testDataFrames[0].fields[0].config.links).toHaveLength(0); + // Loki line + expect(testDataFrames[0].fields[0].config.links).toHaveLength(1); // Loki traceId (linked to Prometheus and Elastic) expect(testDataFrames[0].fields[1].config.links).toHaveLength(2); expect(testDataFrames[0].fields[1].config.links).toMatchObject([ @@ -68,10 +70,29 @@ describe('correlations utils', () => { // Prometheus value (linked to Elastic) expect(testDataFrames[2].fields[0].config.links).toHaveLength(1); }); + + it('changes the config field if loki dataplane is being used and the correlation is pointing to the legacy body field (Line)', () => { + const originalDataplaneState = config.featureToggles.lokiLogsDataplane; + config.featureToggles.lokiLogsDataplane = true; + const { correlations, refIdMap } = setup(); + const testDataFrame = toDataFrame({ + name: 'Loki Logs', + refId: 'Loki Query', + fields: [ + { name: 'timestamp', values: [], type: FieldType.time }, + { name: 'body', values: [], type: FieldType.string }, + { name: 'traceId', values: [], type: FieldType.string }, + ], + meta: { type: DataFrameType.LogLines }, + }); + const dataFrameOut = attachCorrelationsToDataFrames([testDataFrame], [correlations[3]], refIdMap); + expect(dataFrameOut[0].fields[1].config.links).toHaveLength(1); + config.featureToggles.lokiLogsDataplane = originalDataplaneState; + }); }); function setup() { - const loki = { uid: 'loki-uid', name: 'loki' } as DataSourceInstanceSettings; + const loki = { uid: 'loki-uid', name: 'loki', meta: { id: 'loki' } } as DataSourceInstanceSettings; const elastic = { uid: 'elastic-uid', name: 'elastic' } as DataSourceInstanceSettings; const prometheus = { uid: 'prometheus-uid', name: 'prometheus' } as DataSourceInstanceSettings; @@ -86,15 +107,15 @@ function setup() { name: 'Loki Logs', refId: 'Loki Query', fields: [ - { name: 'line', values: [] }, - { name: 'traceId', values: [] }, + { name: 'Line', values: [], type: FieldType.string }, + { name: 'traceId', values: [], type: FieldType.string }, ], }), toDataFrame({ name: 'Elastic Logs', refId: 'Elastic Query', fields: [ - { name: 'line', values: [] }, + { name: 'Line', values: [] }, { name: 'traceId', values: [] }, ], }), @@ -134,7 +155,16 @@ function setup() { config: { field: 'value', target: { expr: 'target Elastic query' } }, provisioned: false, }, + { + uid: 'loki-to-loki', + label: 'logs to logs', + source: loki, + target: loki, + type: 'query', + config: { field: 'Line', target: { expr: 'target loki query' } }, + provisioned: false, + }, ]; - return { testDataFrames, correlations, refIdMap, prometheus, elastic }; + return { testDataFrames, correlations, refIdMap, loki, prometheus, elastic }; } diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index d9719c77e64..f7778bb16bb 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,10 +1,11 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { createMonitoringLogger, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { config, createMonitoringLogger, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; +import { parseLogsFrame } from '../logs/logsFrame'; import { CreateCorrelationParams, CreateCorrelationResponse } from './types'; import { @@ -43,7 +44,7 @@ export const attachCorrelationsToDataFrames = ( } const sourceCorrelations = correlations.filter((correlation) => correlation.source.uid === dataSourceUid); - decorateDataFrameWithInternalDataLinks(dataFrame, sourceCorrelations); + decorateDataFrameWithInternalDataLinks(dataFrame, fixLokiDataplaneFields(sourceCorrelations, dataFrame)); }); return dataFrames; @@ -83,6 +84,27 @@ const decorateDataFrameWithInternalDataLinks = (dataFrame: DataFrame, correlatio }); }; +/* +If a correlation was made based on the log line field prior to the loki data plane, they would use the field "Line" + +Change it to use whatever the body field name is post-loki data plane +*/ +const fixLokiDataplaneFields = (correlations: CorrelationData[], dataFrame: DataFrame) => { + return correlations.map((correlation) => { + if ( + correlation.source.meta?.id === 'loki' && + config.featureToggles.lokiLogsDataplane === true && + correlation.config.field === 'Line' + ) { + const logsFrame = parseLogsFrame(dataFrame); + if (logsFrame != null && logsFrame.bodyField.name !== undefined) { + correlation.config.field = logsFrame?.bodyField.name; + } + } + return correlation; + }); +}; + export const getCorrelationsBySourceUIDs = async (sourceUIDs: string[]): Promise => { return lastValueFrom( getBackendSrv().fetch({ From b662879c983b4b5cba68cf80926de056f9a42d5f Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 19 Nov 2024 15:07:14 +0000 Subject: [PATCH 21/60] Internationalisation: Add basic e2e test to ensure languages load correctly (#96556) * fix pseudo locale and add simple e2e test * add comment * update test and add comments * fix unit test * try 5 instead of 6? * clear input * only check pseudo in dev mode * don't check pseudo in CI * don't test pseudo locale * isolate language changes * move waits around * only wait for preferences on first visit * add proper loading state * bit of tidy up * hook up ids correctly * use disabled/isLoading instead of skeleton * set props on Field --- e2e/various-suite/verify-i18n.spec.ts | 65 +++++++++++++++++++ .../SharedPreferences/SharedPreferences.tsx | 27 ++++++-- .../internationalization/constants.test.ts | 2 +- .../core/internationalization/constants.ts | 2 +- 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 e2e/various-suite/verify-i18n.spec.ts diff --git a/e2e/various-suite/verify-i18n.spec.ts b/e2e/various-suite/verify-i18n.spec.ts new file mode 100644 index 00000000000..3de37d5d863 --- /dev/null +++ b/e2e/various-suite/verify-i18n.spec.ts @@ -0,0 +1,65 @@ +import { e2e } from '../utils'; +import { fromBaseUrl } from '../utils/support/url'; + +describe('Verify i18n', () => { + const I18N_USER = 'i18n-test'; + const I18N_PASSWORD = 'i18n-test'; + + // create a new user to isolate the language changes from other tests + before(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + cy.request({ + method: 'POST', + url: fromBaseUrl('/api/admin/users'), + body: { + email: I18N_USER, + login: I18N_USER, + name: I18N_USER, + password: I18N_PASSWORD, + }, + }).then((response) => { + cy.wrap(response.body.uid).as('uid'); + }); + }); + + // remove the user created in the before hook + after(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + cy.get('@uid').then((uid) => { + cy.request({ + method: 'DELETE', + url: fromBaseUrl(`/api/admin/users/${uid}`), + }); + }); + }); + + beforeEach(() => { + e2e.flows.login(I18N_USER, I18N_PASSWORD); + }); + + // map between languages in the language picker and the corresponding translation of the 'Language' label + const languageMap: Record = { + Deutsch: 'Sprache', + English: 'Language', + Español: 'Idioma', + Français: 'Langue', + 'Português Brasileiro': 'Idioma', + '中文(简体)': '语言', + }; + + // basic test which loops through the defined languages in the picker + // and verifies that the corresponding label is translated correctly + it('loads all the languages correctly', () => { + cy.visit('/profile'); + const LANGUAGE_SELECTOR = '[id="locale-select"]'; + + cy.wrap(Object.entries(languageMap)).each(([language, label]: [string, string]) => { + cy.get(LANGUAGE_SELECTOR).should('not.be.disabled'); + cy.get(LANGUAGE_SELECTOR).click(); + cy.get(LANGUAGE_SELECTOR).clear().type(language).type('{downArrow}{enter}'); + e2e.components.UserProfile.preferencesSaveButton().click(); + cy.contains('label', label).should('be.visible'); + cy.get(LANGUAGE_SELECTOR).should('have.value', language); + }); + }); +}); diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index cd6475c45ac..462e38863a5 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -31,7 +31,9 @@ export interface Props { onConfirm?: () => Promise; } -export type State = UserPreferencesDTO; +export type State = UserPreferencesDTO & { + isLoading: boolean; +}; function getLanguageOptions(): ComboboxOption[] { const languageOptions = LANGUAGES.map((v) => ({ @@ -69,6 +71,7 @@ export class SharedPreferences extends PureComponent { this.service = new PreferencesService(props.resourceUri); this.state = { + isLoading: false, theme: '', timezone: '', weekStart: '', @@ -87,9 +90,13 @@ export class SharedPreferences extends PureComponent { } async componentDidMount() { + this.setState({ + isLoading: true, + }); const prefs = await this.service.load(); this.setState({ + isLoading: false, homeDashboardUID: prefs.homeDashboardUID, theme: prefs.theme, timezone: prefs.timezone, @@ -144,7 +151,7 @@ export class SharedPreferences extends PureComponent { }; render() { - const { theme, timezone, weekStart, homeDashboardUID, language } = this.state; + const { theme, timezone, weekStart, homeDashboardUID, language, isLoading } = this.state; const { disabled } = this.props; const styles = getStyles(); const languages = getLanguageOptions(); @@ -153,7 +160,11 @@ export class SharedPreferences extends PureComponent { return (
Preferences} disabled={disabled}> - + { @@ -183,6 +196,8 @@ export class SharedPreferences extends PureComponent { @@ -195,17 +210,21 @@ export class SharedPreferences extends PureComponent { diff --git a/public/app/core/internationalization/constants.test.ts b/public/app/core/internationalization/constants.test.ts index 6dee3878b36..68defb081b3 100644 --- a/public/app/core/internationalization/constants.test.ts +++ b/public/app/core/internationalization/constants.test.ts @@ -21,7 +21,7 @@ describe('internationalization constants', () => { expect(GERMAN_GERMANY).toBe('de-DE'); expect(BRAZILIAN_PORTUGUESE).toBe('pt-BR'); expect(CHINESE_SIMPLIFIED).toBe('zh-Hans'); - expect(PSEUDO_LOCALE).toBe('pseudo-LOCALE'); + expect(PSEUDO_LOCALE).toBe('pseudo'); expect(DEFAULT_LANGUAGE).toBe(ENGLISH_US); }); diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index f4ab99631cc..b28e5499d92 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -7,7 +7,7 @@ export const SPANISH_SPAIN = 'es-ES'; export const GERMAN_GERMANY = 'de-DE'; export const BRAZILIAN_PORTUGUESE = 'pt-BR'; export const CHINESE_SIMPLIFIED = 'zh-Hans'; -export const PSEUDO_LOCALE = 'pseudo-LOCALE'; +export const PSEUDO_LOCALE = 'pseudo'; export const DEFAULT_LANGUAGE = ENGLISH_US; From 4a071c64910e195162ad845c371b8d7e80031f75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:08:01 +0000 Subject: [PATCH 22/60] Update dependency @grafana/faro-core to v1.12.1 (#96684) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b6c0d4737cb..013f4ee371f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3658,12 +3658,12 @@ __metadata: linkType: hard "@grafana/faro-core@npm:^1.12.0, @grafana/faro-core@npm:^1.3.6": - version: 1.12.0 - resolution: "@grafana/faro-core@npm:1.12.0" + version: 1.12.1 + resolution: "@grafana/faro-core@npm:1.12.1" dependencies: "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/otlp-transformer": "npm:^0.53.0" - checksum: 10/beb747ad360263737e3576ad8d0dccab0828e538797032cf97eed4a3e1c31232750285afb4c3840de80bc25b17defb3ab75976c59622778131311548ebd86100 + checksum: 10/02502bc34723377f06a24ed89e4589654dd5fbeab7e9c31a19291fa088a1237de081f99ba7c5a2032d77d12a686281d16af55064cbf69fad9e52cf8a29d7c000 languageName: node linkType: hard From f3783146bbabdb3349372c5185709bb9c7b21ddb Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 19 Nov 2024 15:27:26 +0000 Subject: [PATCH 23/60] Explore: Sync logs displayed fields with URL state (#96242) * Explore: store displayedFields in URL state * Displayed fields: reset when queries change * Add test * LogRowMessageDisplayedFields: pass logRowMenuIcons props to component * LogsMetaRow: use primary outline for reset button * Logs: clear displayedFields from URL --- packages/grafana-data/src/types/explore.ts | 1 + .../app/features/explore/Logs/Logs.test.tsx | 30 ++++++-- public/app/features/explore/Logs/Logs.tsx | 69 ++++++++++++++++--- .../app/features/explore/Logs/LogsMetaRow.tsx | 2 +- .../app/features/explore/Logs/utils/logs.ts | 15 ++++ .../app/features/logs/components/LogRow.tsx | 2 + .../logs/components/LogRowMessage.test.tsx | 1 - .../LogRowMessageDisplayedFields.test.tsx | 23 +++++++ .../LogRowMessageDisplayedFields.tsx | 19 ++++- 9 files changed, 141 insertions(+), 21 deletions(-) diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 90f21ae568b..f95d90ac409 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -54,6 +54,7 @@ export interface ExploreLogsPanelState { labelFieldName?: string; // Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized refId?: string; + displayedFields?: string[]; } export interface SplitOpenOptions { diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index ec8ddda9938..620f576e118 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -150,12 +150,12 @@ describe('Logs', () => { }, }); - const { rerender } = render( + const rendered = render( {getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)} ); - return { rerender, store: fakeStore }; + return { ...rendered, store: fakeStore }; }; describe('scrolling behavior', () => { @@ -428,12 +428,14 @@ describe('Logs', () => { }); it('should call createAndCopyShortLink on permalinkClick - logs', async () => { - const panelState: Partial = { logs: { id: 'not-included', visualisationType: 'logs' } }; + const panelState: Partial = { + logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, + }; const rows = [ - makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), - makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), - makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), - makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }), + makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: '1' } }), + makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1, labels: { field: '2' } }), + makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2, labels: { field: '3' } }), + makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2, labels: { field: '4' } }), ]; setup({ loading: false, panelState, logRows: rows }); @@ -449,6 +451,7 @@ describe('Logs', () => { ) ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); + expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('displayedFields%22:%5B%22field')); }); it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => { @@ -480,6 +483,19 @@ describe('Logs', () => { }); }); + describe('displayed fields', () => { + it('should sync displayed fields from the URL', async () => { + const panelState: Partial = { + logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, + }; + const rows = [makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: 'field value' } })]; + setup({ loading: false, panelState, logRows: rows }); + + expect(await screen.findByText('field=field value')).toBeInTheDocument(); + expect(screen.queryByText(/log message/)).not.toBeInTheDocument(); + }); + }); + describe('with table visualisation', () => { let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index d04c45fe2fb..8da155b1142 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -10,7 +10,6 @@ import { LogRowModel, LogsMetaItem, DataFrame, - DataQuery, AbsoluteTimeRange, GrafanaTheme2, LoadingState, @@ -37,6 +36,7 @@ import { urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { Button, InlineField, @@ -79,7 +79,7 @@ import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; -import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; +import { canKeepDisplayedFields, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; interface Props extends Themeable2 { width: number; @@ -221,6 +221,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const cancelFlippingTimer = useRef(undefined); const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {}); const topLogsRef = useRef(null); + const prevLogsQueries = usePrevious(logsQueries); const tableHeight = getLogsTableHeight(); const styles = getStyles(theme, wrapLogMessage, tableHeight); @@ -330,6 +331,16 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { store.set(visualisationTypeKey, visualisationType); }, [panelState?.logs?.visualisationType]); + useEffect(() => { + let displayedFields: string[] = []; + if (Array.isArray(panelState?.logs?.displayedFields)) { + displayedFields = panelState?.logs?.displayedFields; + } else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') { + displayedFields = Object.values(panelState?.logs?.displayedFields); + } + setDisplayedFields(displayedFields); + }, [panelState?.logs?.displayedFields]); + useEffect(() => { registerLogLevelsWithContentOutline(); }, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]); @@ -344,8 +355,13 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { }); useUnmount(() => { - // If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url - if (panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName) { + // If we're unmounting logs (e.g. switching to another datasource), we need to remove the logs specific panel state, otherwise it will persist in the explore url + if ( + panelState?.logs?.columns || + panelState?.logs?.refId || + panelState?.logs?.labelFieldName || + panelState?.logs?.displayedFields + ) { dispatch( changePanelState(exploreId, 'logs', { ...panelState?.logs, @@ -353,6 +369,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { visualisationType: visualisationType, labelFieldName: undefined, refId: undefined, + displayedFields: undefined, }) ); } @@ -369,13 +386,35 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { visualisationType: logsPanelState.visualisationType ?? visualisationType, labelFieldName: logsPanelState.labelFieldName, refId: logsPanelState.refId ?? panelState?.logs?.refId, + displayedFields: logsPanelState.displayedFields ?? panelState?.logs?.displayedFields, }) ); } }, - [dispatch, exploreId, panelState?.logs?.columns, panelState?.logs?.refId, visualisationType] + [ + dispatch, + exploreId, + panelState?.logs?.columns, + panelState?.logs?.displayedFields, + panelState?.logs?.refId, + visualisationType, + ] ); + useEffect(() => { + if (!prevLogsQueries) { + // Initial load, ignore + return; + } + if (!canKeepDisplayedFields(logsQueries, prevLogsQueries)) { + setDisplayedFields([]); + updatePanelState({ + ...panelState?.logs, + displayedFields: [], + }); + } + }, [logsQueries, panelState?.logs, prevLogsQueries, updatePanelState]); + // actions const onLogRowHover = useCallback( (row?: LogRowModel) => { @@ -559,20 +598,30 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const index = displayedFields.indexOf(key); if (index === -1) { - setDisplayedFields(displayedFields.concat(key)); + const updatedDisplayedFields = displayedFields.concat(key); + setDisplayedFields(updatedDisplayedFields); + updatePanelState({ + ...panelState?.logs, + displayedFields: updatedDisplayedFields, + }); } }, - [displayedFields] + [displayedFields, panelState?.logs, updatePanelState] ); const hideField = useCallback( (key: string) => { const index = displayedFields.indexOf(key); if (index > -1) { - setDisplayedFields(displayedFields.filter((k) => key !== k)); + const updatedDisplayedFields = displayedFields.filter((k) => key !== k); + setDisplayedFields(updatedDisplayedFields); + updatePanelState({ + ...panelState?.logs, + displayedFields: updatedDisplayedFields, + }); } }, - [displayedFields] + [displayedFields, panelState?.logs, updatePanelState] ); const clearDetectedFields = useCallback(() => { @@ -658,7 +707,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { ...panelState, - logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType() }, + logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, }; urlState.range = getPermalinkRange(row); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 26f59a2689a..abf22494946 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -139,7 +139,7 @@ export const LogsMetaRow = memo( { label: '', value: ( - ), diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts index 2aec91da980..61e4f9ce5d5 100644 --- a/public/app/features/explore/Logs/utils/logs.ts +++ b/public/app/features/explore/Logs/utils/logs.ts @@ -1,3 +1,6 @@ +import { shallowCompare } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; + export const SETTINGS_KEYS = { showLabels: 'grafana.explore.logs.showLabels', showTime: 'grafana.explore.logs.showTime', @@ -8,3 +11,15 @@ export const SETTINGS_KEYS = { }; export const visualisationTypeKey = 'grafana.explore.logs.visualisationType'; + +export const canKeepDisplayedFields = (logsQueries: DataQuery[] | undefined, prevLogsQueries: DataQuery[]): boolean => { + if (!logsQueries) { + return false; + } + for (let i = 0; i < logsQueries.length; i++) { + if (!shallowCompare(logsQueries[i], prevLogsQueries[i])) { + return false; + } + } + return true; +}; diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 686f49e19a4..6a59f32dbfc 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -299,6 +299,8 @@ class UnThemedLogRow extends PureComponent { pinned={this.props.pinned} mouseIsOver={this.state.mouseIsOver} onBlur={this.onMouseLeave} + logRowMenuIconsBefore={logRowMenuIconsBefore} + logRowMenuIconsAfter={logRowMenuIconsAfter} /> ) : ( { expect(screen.getByText(/place=Earth/)).toBeInTheDocument(); expect(screen.getByText(/planet=Mars/)).toBeInTheDocument(); }); + + describe('With custom buttons', () => { + it('supports custom buttons before and after the default options', async () => { + const onBefore = jest.fn(); + const logRowMenuIconsBefore = [ + , + ]; + const onAfter = jest.fn(); + const logRowMenuIconsAfter = [ + , + ]; + + const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter }); + + await userEvent.click(screen.getByLabelText('Addon before')); + await userEvent.click(screen.getByLabelText('Addon after')); + + expect(onBefore).toHaveBeenCalledWith(expect.anything(), row); + expect(onAfter).toHaveBeenCalledWith(expect.anything(), row); + }); + }); }); diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 5391dd0cce9..363fb97bb17 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo, useMemo } from 'react'; +import { memo, ReactNode, useMemo } from 'react'; import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data'; @@ -21,10 +21,23 @@ export interface Props { pinned?: boolean; mouseIsOver: boolean; onBlur: () => void; + logRowMenuIconsBefore?: ReactNode[]; + logRowMenuIconsAfter?: ReactNode[]; } export const LogRowMessageDisplayedFields = memo((props: Props) => { - const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, mouseIsOver, pinned, ...rest } = props; + const { + row, + detectedFields, + getFieldLinks, + wrapLogMessage, + styles, + mouseIsOver, + pinned, + logRowMenuIconsBefore, + logRowMenuIconsAfter, + ...rest + } = props; const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; const fields = useMemo(() => getAllFields(row, getFieldLinks), [getFieldLinks, row]); // only single key/value rows are filterable, so we only need the first field key for filtering @@ -63,6 +76,8 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => { styles={styles} pinned={pinned} mouseIsOver={mouseIsOver} + addonBefore={logRowMenuIconsBefore} + addonAfter={logRowMenuIconsAfter} {...rest} /> )} From 306f9e5b91ebe700c8ec70b79d2181e9b6fc2bd8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:29:05 +0000 Subject: [PATCH 24/60] Update dependency @grafana/faro-web-sdk to v1.12.1 (#96685) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 013f4ee371f..9075aeda4a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3657,7 +3657,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.12.0, @grafana/faro-core@npm:^1.3.6": +"@grafana/faro-core@npm:^1.12.0, @grafana/faro-core@npm:^1.12.1, @grafana/faro-core@npm:^1.3.6": version: 1.12.1 resolution: "@grafana/faro-core@npm:1.12.1" dependencies: @@ -3667,7 +3667,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.12.0, @grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.12.0": version: 1.12.0 resolution: "@grafana/faro-web-sdk@npm:1.12.0" dependencies: @@ -3678,6 +3678,17 @@ __metadata: languageName: node linkType: hard +"@grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": + version: 1.12.1 + resolution: "@grafana/faro-web-sdk@npm:1.12.1" + dependencies: + "@grafana/faro-core": "npm:^1.12.1" + ua-parser-js: "npm:^1.0.32" + web-vitals: "npm:^4.0.1" + checksum: 10/7a352846946e1cb240ff405ef88b71e710eb049f307740a1acbf64e879422a9a44956bc2c5fa6b73c8ac742090f6c96e64254470209e476653d5d4649808a7d0 + languageName: node + linkType: hard + "@grafana/faro-web-tracing@npm:^1.8.2": version: 1.12.0 resolution: "@grafana/faro-web-tracing@npm:1.12.0" From 19c94301ff8d29636bb163aeafaab835e8c8a790 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:30:22 +0000 Subject: [PATCH 25/60] Release: update changelog for 10.4.13 (#96674) Update changelog Co-authored-by: github-actions[bot] --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af808ed98a1..e9dfe645204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ + + +# 10.4.13 (2024-11-19) + + # 11.3.0+security-01 (2024-11-12) From 6d97d170f6299aa925173107f8fde35960e08d0c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:48:08 +0000 Subject: [PATCH 26/60] Update dependency @grafana/faro-web-sdk to v1.12.1 (#96688) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-prometheus/package.json | 2 +- yarn.lock | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 6d241e46306..552845437cd 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,7 +40,7 @@ "@floating-ui/react": "0.26.28", "@grafana/data": "11.4.0-pre", "@grafana/experimental": "2.1.2", - "@grafana/faro-web-sdk": "1.12.0", + "@grafana/faro-web-sdk": "1.12.1", "@grafana/runtime": "11.4.0-pre", "@grafana/schema": "11.4.0-pre", "@grafana/ui": "11.4.0-pre", diff --git a/yarn.lock b/yarn.lock index 9075aeda4a6..39926c3f887 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3657,7 +3657,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.12.0, @grafana/faro-core@npm:^1.12.1, @grafana/faro-core@npm:^1.3.6": +"@grafana/faro-core@npm:^1.12.1, @grafana/faro-core@npm:^1.3.6": version: 1.12.1 resolution: "@grafana/faro-core@npm:1.12.1" dependencies: @@ -3667,18 +3667,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.12.0": - version: 1.12.0 - resolution: "@grafana/faro-web-sdk@npm:1.12.0" - dependencies: - "@grafana/faro-core": "npm:^1.12.0" - ua-parser-js: "npm:^1.0.32" - web-vitals: "npm:^4.0.1" - checksum: 10/220c31d90b18150e42ab69ace544a10ca8bc3710f95d44d95dd5ea8d8318dc6938daec67a5ab0c288b0470edda373a796ea32f231c71c6fb1f248d1f8632b397 - languageName: node - linkType: hard - -"@grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.12.1, @grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": version: 1.12.1 resolution: "@grafana/faro-web-sdk@npm:1.12.1" dependencies: @@ -3875,7 +3864,7 @@ __metadata: "@grafana/data": "npm:11.4.0-pre" "@grafana/e2e-selectors": "npm:11.4.0-pre" "@grafana/experimental": "npm:2.1.2" - "@grafana/faro-web-sdk": "npm:1.12.0" + "@grafana/faro-web-sdk": "npm:1.12.1" "@grafana/runtime": "npm:11.4.0-pre" "@grafana/schema": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" From bf74b9c9c068deef0b854bc350ee264d926a7000 Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Tue, 19 Nov 2024 15:51:07 +0000 Subject: [PATCH 27/60] Tempo: Run TraceQL metrics queries through backend (#96246) * Move TraceQL metrics queries to backend * Add tests for TransformMetricsResponse * Add tests for createMetricsQuery * Refactor, detect metric queries and throw error for search queries * Remove unneeded types * Don't add start and end if they are 0. Return on err * lint * Fix pre-alloc of frames * Address PR comments --- pkg/tsdb/tempo/tempo.go | 5 +- pkg/tsdb/tempo/trace_transform.go | 3 +- pkg/tsdb/tempo/traceql/metrics.go | 76 ++++++++ pkg/tsdb/tempo/traceql/metrics_test.go | 112 ++++++++++++ pkg/tsdb/tempo/traceql_query.go | 170 ++++++++++++++++++ pkg/tsdb/tempo/traceql_query_test.go | 133 ++++++++++++++ .../plugins/datasource/tempo/datasource.ts | 37 ++-- .../datasource/tempo/resultTransformer.ts | 61 +------ public/app/plugins/datasource/tempo/types.ts | 29 --- 9 files changed, 510 insertions(+), 116 deletions(-) create mode 100644 pkg/tsdb/tempo/traceql/metrics.go create mode 100644 pkg/tsdb/tempo/traceql/metrics_test.go create mode 100644 pkg/tsdb/tempo/traceql_query.go create mode 100644 pkg/tsdb/tempo/traceql_query_test.go diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go index 311993714da..96a295b7fac 100644 --- a/pkg/tsdb/tempo/tempo.go +++ b/pkg/tsdb/tempo/tempo.go @@ -109,8 +109,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) } func (s *Service) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) { - if query.QueryType == string(dataquery.TempoQueryTypeTraceId) { + switch query.QueryType { + case string(dataquery.TempoQueryTypeTraceId): return s.getTrace(ctx, pCtx, query) + case string(dataquery.TempoQueryTypeTraceql): + return s.runTraceQlQuery(ctx, pCtx, query) } return nil, fmt.Errorf("unsupported query type: '%s' for query with refID '%s'", query.QueryType, query.RefID) } diff --git a/pkg/tsdb/tempo/trace_transform.go b/pkg/tsdb/tempo/trace_transform.go index 047db53adf0..971e9d1dd5d 100644 --- a/pkg/tsdb/tempo/trace_transform.go +++ b/pkg/tsdb/tempo/trace_transform.go @@ -62,8 +62,7 @@ func TraceToFrame(resourceSpans []*tracev11.ResourceSpans) (*data.Frame, error) data.NewField("tags", nil, []json.RawMessage{}), }, Meta: &data.FrameMeta{ - // TODO: use constant once available in the SDK - PreferredVisualization: "trace", + PreferredVisualization: data.VisTypeTrace, }, } diff --git a/pkg/tsdb/tempo/traceql/metrics.go b/pkg/tsdb/tempo/traceql/metrics.go new file mode 100644 index 00000000000..8d384089405 --- /dev/null +++ b/pkg/tsdb/tempo/traceql/metrics.go @@ -0,0 +1,76 @@ +package traceql + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/tempo/pkg/tempopb" + v1 "github.com/grafana/tempo/pkg/tempopb/common/v1" +) + +func TransformMetricsResponse(resp tempopb.QueryRangeResponse) []*data.Frame { + // prealloc frames + frames := make([]*data.Frame, len(resp.Series)) + for i, series := range resp.Series { + labels := make(data.Labels) + for _, label := range series.Labels { + labels[label.GetKey()] = metricsValueToString(label.GetValue()) + } + + name := "" + if len(series.Labels) > 0 { + if len(series.Labels) == 1 { + name = metricsValueToString(series.Labels[0].GetValue()) + } else { + var labelStrings []string + for key, val := range labels { + labelStrings = append(labelStrings, fmt.Sprintf("%s=%s", key, val)) + } + name = fmt.Sprintf("{%s}", strings.Join(labelStrings, ", ")) + } + } + + valueField := data.NewField(name, labels, []float64{}) + valueField.Config = &data.FieldConfig{ + DisplayName: name, + } + + timeField := data.NewField("time", nil, []time.Time{}) + + frame := &data.Frame{ + RefID: name, + Name: "Trace", + Fields: []*data.Field{ + timeField, + valueField, + }, + Meta: &data.FrameMeta{ + PreferredVisualization: data.VisTypeGraph, + }, + } + + for _, sample := range series.Samples { + frame.AppendRow(time.UnixMilli(sample.GetTimestampMs()), sample.GetValue()) + } + + frames[i] = frame + } + return frames +} + +func metricsValueToString(value *v1.AnyValue) string { + switch value.GetValue().(type) { + case *v1.AnyValue_DoubleValue: + return strconv.FormatFloat(value.GetDoubleValue(), 'f', -1, 64) + case *v1.AnyValue_IntValue: + return strconv.FormatInt(value.GetIntValue(), 10) + case *v1.AnyValue_StringValue: + return fmt.Sprintf("\"%s\"", value.GetStringValue()) + case *v1.AnyValue_BoolValue: + return strconv.FormatBool(value.GetBoolValue()) + } + return "" +} diff --git a/pkg/tsdb/tempo/traceql/metrics_test.go b/pkg/tsdb/tempo/traceql/metrics_test.go new file mode 100644 index 00000000000..79d18369e37 --- /dev/null +++ b/pkg/tsdb/tempo/traceql/metrics_test.go @@ -0,0 +1,112 @@ +package traceql + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/tempo/pkg/tempopb" + v1 "github.com/grafana/tempo/pkg/tempopb/common/v1" + "github.com/stretchr/testify/assert" +) + +func TestTransformMetricsResponse_EmptyResponse(t *testing.T) { + resp := tempopb.QueryRangeResponse{} + frames := TransformMetricsResponse(resp) + assert.Empty(t, frames) +} + +func TestTransformMetricsResponse_SingleSeriesSingleLabel(t *testing.T) { + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 1) + assert.Equal(t, "\"value1\"", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "\"value1\"", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) +} + +func TestTransformMetricsResponse_SingleSeriesMultipleLabels(t *testing.T) { + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + {Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 123}}}, + {Key: "label3", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 123.456}}}, + {Key: "label4", Value: &v1.AnyValue{Value: &v1.AnyValue_BoolValue{BoolValue: true}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 1) + assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) +} + +func TestTransformMetricsResponse_MultipleSeries(t *testing.T) { + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + { + Labels: []v1.KeyValue{ + {Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 456}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 4.56}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 2) + assert.Equal(t, "\"value1\"", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "\"value1\"", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) + + assert.Equal(t, "456", frames[1].RefID) + assert.Equal(t, "Trace", frames[1].Name) + assert.Len(t, frames[1].Fields, 2) + assert.Equal(t, "time", frames[1].Fields[0].Name) + assert.Equal(t, "456", frames[1].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[1].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[1].Fields[0].At(0)) + assert.Equal(t, 4.56, frames[1].Fields[1].At(0)) +} diff --git a/pkg/tsdb/tempo/traceql_query.go b/pkg/tsdb/tempo/traceql_query.go new file mode 100644 index 00000000000..31dc60d6b48 --- /dev/null +++ b/pkg/tsdb/tempo/traceql_query.go @@ -0,0 +1,170 @@ +package tempo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + + //nolint:all + "github.com/golang/protobuf/jsonpb" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" + "github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery" + "github.com/grafana/grafana/pkg/tsdb/tempo/traceql" + "github.com/grafana/tempo/pkg/tempopb" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func (s *Service) runTraceQlQuery(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery) (*backend.DataResponse, error) { + ctxLogger := s.logger.FromContext(ctx) + ctxLogger.Debug("Running TraceQL query", "function", logEntrypoint()) + + tempoQuery := &dataquery.TempoQuery{} + err := json.Unmarshal(backendQuery.JSON, tempoQuery) + if err != nil { + ctxLogger.Error("Failed to unmarshall Tempo query model", "error", err, "function", logEntrypoint()) + return nil, err + } + + if isMetricsQuery(*tempoQuery.Query) { + return s.runTraceQlQueryMetrics(ctx, pCtx, backendQuery, tempoQuery) + } + + return s.runTraceQlQuerySearch() +} + +func (s *Service) runTraceQlQuerySearch() (*backend.DataResponse, error) { + return nil, fmt.Errorf("backend TraceQL search queries are not supported") +} + +func (s *Service) runTraceQlQueryMetrics(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery, tempoQuery *dataquery.TempoQuery) (*backend.DataResponse, error) { + ctxLogger := s.logger.FromContext(ctx) + ctxLogger.Debug("Running TraceQL Metrics query", "function", logEntrypoint()) + + ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runTraceQLQuery", trace.WithAttributes( + attribute.String("queryType", backendQuery.QueryType), + )) + defer span.End() + + result := &backend.DataResponse{} + + dsInfo, err := s.getDSInfo(ctx, pCtx) + if err != nil { + ctxLogger.Error("Failed to get datasource information", "error", err, "function", logEntrypoint()) + return nil, err + } + + if tempoQuery.Query == nil || *tempoQuery.Query == "" { + err := fmt.Errorf("query is required") + ctxLogger.Error("Failed to validate model query", "error", err, "function", logEntrypoint()) + return result, err + } + + resp, responseBody, err := s.performMetricsQuery(ctx, dsInfo, tempoQuery, backendQuery, span) + defer func() { + if err := resp.Body.Close(); err != nil { + ctxLogger.Error("Failed to close response body", "error", err, "function", logEntrypoint()) + } + }() + if err != nil { + return result, err + } + + if resp.StatusCode != http.StatusOK { + ctxLogger.Error("Failed to execute TraceQL query", "error", err, "function", logEntrypoint()) + result.Error = fmt.Errorf("failed to execute TraceQL query: %s Status: %s Body: %s", *tempoQuery.Query, resp.Status, string(responseBody)) + span.RecordError(result.Error) + span.SetStatus(codes.Error, result.Error.Error()) + return result, nil + } + + var queryResponse tempopb.QueryRangeResponse + err = jsonpb.Unmarshal(bytes.NewReader(responseBody), &queryResponse) + + if err != nil { + ctxLogger.Error("Failed to convert response to type", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return &backend.DataResponse{}, fmt.Errorf("failed to convert response to type: %w", err) + } + + frames := traceql.TransformMetricsResponse(queryResponse) + + result.Frames = frames + ctxLogger.Debug("Successfully performed TraceQL query", "function", logEntrypoint()) + return result, nil +} + +func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *Datasource, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { + ctxLogger := s.logger.FromContext(ctx) + request, err := s.createMetricsQuery(ctx, dsInfo, model, query.TimeRange.From.Unix(), query.TimeRange.To.Unix()) + + if err != nil { + ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, nil, err + } + + resp, err := dsInfo.HTTPClient.Do(request) + if err != nil { + ctxLogger.Error("Failed to send request to Tempo", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, nil, fmt.Errorf("failed get to tempo: %w", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + ctxLogger.Error("Failed to read response body", "error", err, "function", logEntrypoint()) + return nil, nil, err + } + return resp, body, nil +} + +func (s *Service) createMetricsQuery(ctx context.Context, dsInfo *Datasource, query *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) { + ctxLogger := s.logger.FromContext(ctx) + + rawUrl := fmt.Sprintf("%s/api/metrics/query_range", dsInfo.URL) + searchUrl, err := url.Parse(rawUrl) + if err != nil { + ctxLogger.Error("Failed to parse URL", "url", rawUrl, "error", err, "function", logEntrypoint()) + return nil, err + } + + q := searchUrl.Query() + q.Set("q", *query.Query) + if start > 0 { + q.Set("start", strconv.FormatInt(start, 10)) + } + if end > 0 { + q.Set("end", strconv.FormatInt(end, 10)) + } + if query.Step != nil { + q.Set("step", *query.Step) + } + + searchUrl.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", searchUrl.String(), nil) + if err != nil { + ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint()) + return nil, err + } + + req.Header.Set("Accept", "application/json") + return req, nil +} + +func isMetricsQuery(query string) bool { + match, _ := regexp.MatchString("\\|\\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time|histogram_over_time|compare)\\s*\\(", query) + return match +} diff --git a/pkg/tsdb/tempo/traceql_query_test.go b/pkg/tsdb/tempo/traceql_query_test.go new file mode 100644 index 00000000000..f8256cd0b04 --- /dev/null +++ b/pkg/tsdb/tempo/traceql_query_test.go @@ -0,0 +1,133 @@ +package tempo + +import ( + "context" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery" + "github.com/stretchr/testify/assert" +) + +func TestCreateMetricsQuery_Success(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://tempo:3100", + } + queryVal := "{attribute=\"value\"}" + stepVal := "14" + query := &dataquery.TempoQuery{ + Query: &queryVal, + Step: &stepVal, + } + start := int64(1625097600) + end := int64(1625184000) + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end) + assert.NoError(t, err) + assert.NotNil(t, req) + assert.Equal(t, "http://tempo:3100/api/metrics/query_range?end=1625184000&q=%7Battribute%3D%22value%22%7D&start=1625097600&step=14", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Accept")) +} +func TestCreateMetricsQuery_OnlyQuery(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://tempo:3100", + } + queryVal := "{attribute=\"value\"}" + query := &dataquery.TempoQuery{ + Query: &queryVal, + } + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, 0, 0) + assert.NoError(t, err) + assert.NotNil(t, req) + assert.Equal(t, "http://tempo:3100/api/metrics/query_range?q=%7Battribute%3D%22value%22%7D", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Accept")) +} + +func TestCreateMetricsQuery_URLParseError(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://[::1]:namedport", + } + queryVal := "{attribute=\"value\"}" + query := &dataquery.TempoQuery{ + Query: &queryVal, + } + start := int64(1625097600) + end := int64(1625184000) + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end) + assert.Error(t, err) + assert.Nil(t, req) +} + +func TestEmptyQueryString_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("") + assert.False(t, result) +} + +func TestQueryWithoutMetricsFunction_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("{.some = \"random query\"} && {} >> {}") + assert.False(t, result) +} + +func TestQueryWithRateFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | rate(foo)") + assert.True(t, result) +} + +func TestQueryWithAvgOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | avg_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithCountOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | count_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithMaxOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | max_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithMinOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | min_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithQuantileOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | quantile_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithHistogramOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | histogram_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithCompareFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | compare(foo)") + assert.True(t, result) +} + +func TestQueryWithMultipleFunctions_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | rate(foo) | avg_over_time(bar)") + assert.True(t, result) +} + +func TestQueryWithInvalidFunction_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("{} | invalid_function(foo)") + assert.False(t, result) +} diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index ffaee3bd7cc..616a26d6505 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -51,12 +51,7 @@ import { } from './graphTransform'; import TempoLanguageProvider from './language_provider'; import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary'; -import { - formatTraceQLMetrics, - formatTraceQLResponse, - transformFromOTLP as transformFromOTEL, - transformTrace, -} from './resultTransformer'; +import { formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace } from './resultTransformer'; import { doTempoChannelStream } from './streaming'; import { TempoJsonData, TempoQuery } from './types'; import { getErrorMessage, migrateFromSearchToTraceQLSearch } from './utils'; @@ -354,7 +349,7 @@ export class TempoDatasource extends DataSourceWithBackend, - queryValue: string - ): Observable => { - const requestData = { - query: queryValue, - start: options.range.from.unix(), - end: options.range.to.unix(), - step: options.targets[0].step, - }; - - if (!requestData.step) { - delete requestData.step; + handleTraceQlMetricsQuery = (options: DataQueryRequest): Observable => { + const validTargets = options.targets + .filter((t) => t.query) + .map( + (t): TempoQuery => ({ ...t, query: this.applyVariables(t, options.scopedVars).query, queryType: 'traceql' }) + ); + if (!validTargets.length) { + return EMPTY; } - return this._request('/api/metrics/query_range', requestData).pipe( + const request = { ...options, targets: validTargets }; + return super.query(request).pipe( map((response) => { - return { - data: formatTraceQLMetrics(queryValue, response.data), - }; + return response; }), catchError((err) => { return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index 4557b1137f7..52e9424c514 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -15,7 +15,6 @@ import { FieldDTO, FieldType, getDisplayProcessor, - Labels, MutableDataFrame, toDataFrame, TraceKeyValuePair, @@ -28,15 +27,7 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { SearchTableType } from './dataquery.gen'; import { createGraphFrames } from './graphTransform'; -import { - ProtoValue, - Span, - SpanAttributes, - Spanset, - TempoJsonData, - TraceqlMetricsResponse, - TraceSearchMetadata, -} from './types'; +import { Span, SpanAttributes, Spanset, TempoJsonData, TraceSearchMetadata } from './types'; function getAttributeValue(value: collectorTypes.opentelemetryProto.common.v1.AnyValue): any { if (value.stringValue) { @@ -474,56 +465,6 @@ function transformToTraceData(data: TraceSearchMetadata) { }; } -const metricsValueToString = (value: ProtoValue): string => { - if (value.stringValue) { - return `"${value.stringValue}"`; - } - return '' + (value.intValue || value.doubleValue || value.boolValue || '""'); -}; - -export function formatTraceQLMetrics(query: string, data: TraceqlMetricsResponse) { - const frames = data.series.map((series, index) => { - const labels: Labels = {}; - series.labels?.forEach((label) => { - labels[label.key] = metricsValueToString(label.value); - }); - // If it's a single series, use the query as the displayName fallback - let name = data.series.length === 1 ? query : ''; - if (series.labels) { - if (series.labels.length === 1) { - // For single label series, use the label value as the displayName to improve readability - name = metricsValueToString(series.labels[0].value); - } else { - // otherwise build a string using the label keys and values - name = `{${series.labels.map((label) => `${label.key}=${metricsValueToString(label.value)}`).join(', ')}}`; - } - } - return createDataFrame({ - refId: name || `A${index}`, - fields: [ - { - name: 'time', - type: FieldType.time, - values: series.samples.map((sample) => parseInt(sample.timestampMs, 10)), - }, - { - name: name, - labels, - type: FieldType.number, - values: series.samples.map((sample) => sample.value), - config: { - displayNameFromDS: name, - }, - }, - ], - meta: { - preferredVisualisationType: 'graph', - }, - }); - }); - return frames; -} - export function formatTraceQLResponse( data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings, diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index 31439867edd..c671a1bf15a 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -106,32 +106,3 @@ export type Scope = { name: string; tags: string[]; }; - -// Maps to QueryRangeResponse of tempopb https://github.com/grafana/tempo/blob/cfda98fc5cb0777963f41e0949b9ad2d24b4b5b8/pkg/tempopb/tempo.proto#L360 -export type TraceqlMetricsResponse = { - series: MetricsSeries[]; - metrics: SearchMetrics; -}; - -export type MetricsSeries = { - labels: MetricsSeriesLabel[]; - samples: MetricsSeriesSample[]; - promLabels: string; -}; - -export type MetricsSeriesLabel = { - key: string; - value: ProtoValue; -}; - -export type ProtoValue = { - stringValue?: string; - intValue?: string; - boolValue?: boolean; - doubleValue?: string; -}; - -export type MetricsSeriesSample = { - timestampMs: string; - value: number; -}; From f44bc2bfd85a052fcee299964bd442ac2dce54e8 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Tue, 19 Nov 2024 16:55:05 +0100 Subject: [PATCH 28/60] Logs: Let `level` label take precedence over `detected_level` (#96678) --- public/app/features/logs/legacyLogsFrame.ts | 2 +- public/app/features/logs/logsModel.ts | 4 ++-- public/app/plugins/datasource/loki/responseUtils.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/features/logs/legacyLogsFrame.ts b/public/app/features/logs/legacyLogsFrame.ts index e6b3bbc4399..902c973a093 100644 --- a/public/app/features/logs/legacyLogsFrame.ts +++ b/public/app/features/logs/legacyLogsFrame.ts @@ -45,7 +45,7 @@ export function parseLegacyLogsFrame(frame: DataFrame): LogsFrame | null { } const timeNanosecondField = cache.getFieldByName('tsNs') ?? null; - const severityField = cache.getFieldByName('detected_level') ?? cache.getFieldByName('level') ?? null; + const severityField = cache.getFieldByName('level') ?? cache.getFieldByName('detected_level') ?? null; const idField = cache.getFieldByName('id') ?? null; // extracting the labels is done very differently for old-loki-style and simple-style diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index 9be17b8dec1..a298b7f0bb9 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -426,7 +426,7 @@ export function logSeriesToLogsModel( } let logLevel = LogLevel.unknown; - const logLevelKey = (logLevelField && logLevelField.values[j]) || (labels?.detected_level ?? labels?.level); + const logLevelKey = (logLevelField && logLevelField.values[j]) || (labels?.level ?? labels?.detected_level); if (typeof logLevelKey === 'number' || typeof logLevelKey === 'string') { logLevel = getLogLevelFromKey(logLevelKey); } else { @@ -643,7 +643,7 @@ function defaultExtractLevel(dataFrame: DataFrame): LogLevel { } function getLogLevelFromLabels(labels: Labels): LogLevel { - const level = labels['detected_level'] ?? labels['level'] ?? labels['lvl'] ?? labels['loglevel'] ?? ''; + const level = labels['level'] ?? labels['detected_level'] ?? labels['lvl'] ?? labels['loglevel'] ?? ''; return level ? getLogLevelFromKey(level) : LogLevel.unknown; } diff --git a/public/app/plugins/datasource/loki/responseUtils.ts b/public/app/plugins/datasource/loki/responseUtils.ts index dcfafdc670a..95c9112b91c 100644 --- a/public/app/plugins/datasource/loki/responseUtils.ts +++ b/public/app/plugins/datasource/loki/responseUtils.ts @@ -122,7 +122,7 @@ export function extractLevelLikeLabelFromDataFrame(frame: DataFrame): string | n // Find first level-like label for (let labels of labelsArray) { const label = Object.keys(labels).find( - (label) => label === 'detected_level' || label === 'level' || label === 'lvl' || label.includes('level') + (label) => label === 'level' || label === 'detected_level' || label === 'lvl' || label.includes('level') ); if (label) { levelLikeLabel = label; From 005de7db95e307ba3df6fc670d7294c568f3ef6d Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 19 Nov 2024 10:12:53 -0600 Subject: [PATCH 29/60] Correlations: Handle special field for loki labels (using data plane) (#94033) Use dataplane data if available --- public/app/features/explore/utils/links.ts | 14 ++++++++++++++ public/app/features/logs/logsFrame.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index e36ba898134..f837c36a2fb 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -19,11 +19,13 @@ import { DataLinkPostProcessor, ExploreUrlState, urlUtil, + DataFrameType, } from '@grafana/data'; import { getTemplateSrv, reportInteraction, VariableInterpolation } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { getTransformationVars } from 'app/features/correlations/transformations'; +import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame'; import { ExploreItemState } from 'app/types/explore'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; @@ -137,6 +139,18 @@ export const getFieldLinksForExplore = (options: { text: 'Data', }; + if (dataFrame.meta?.type === DataFrameType.LogLines) { + const dataPlane = parseDataplaneLogsFrame(dataFrame); + const labels = dataPlane?.getLogFrameLabels(); + if (labels != null) { + Object.entries(labels[rowIndex]).forEach((value) => { + scopedVars[value[0]] = { + value: value[1], + }; + }); + } + } + dataFrame.fields.forEach((f) => { if (fieldDisplayValuesProxy && fieldDisplayValuesProxy[f.name]) { scopedVars[f.name] = { diff --git a/public/app/features/logs/logsFrame.ts b/public/app/features/logs/logsFrame.ts index 513f56a3e1e..c8e00f96319 100644 --- a/public/app/features/logs/logsFrame.ts +++ b/public/app/features/logs/logsFrame.ts @@ -45,7 +45,7 @@ export function logFrameLabelsToLabels(logFrameLabels: LogFrameLabels): Labels { return result; } -function parseDataplaneLogsFrame(frame: DataFrame): LogsFrame | null { +export function parseDataplaneLogsFrame(frame: DataFrame): LogsFrame | null { const cache = new FieldCache(frame); const timestampField = getField(cache, DATAPLANE_TIMESTAMP_NAME, FieldType.time); From d3a000e7da4870aa399cf62b31055916fe7217b1 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:17:42 +0000 Subject: [PATCH 30/60] Release: update changelog for 11.0.8 (#96690) Update changelog Co-authored-by: github-actions[bot] --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9dfe645204..d4683bc9985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ + + +# 11.0.8 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96131](https://github.com/grafana/grafana/pull/96131), [@fayzal-g](https://github.com/fayzal-g) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95487](https://github.com/grafana/grafana/pull/95487), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Anonymous User:** Adds validator service for anonymous users [#95151](https://github.com/grafana/grafana/pull/95151), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95152](https://github.com/grafana/grafana/pull/95152), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95244](https://github.com/grafana/grafana/pull/95244), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95906](https://github.com/grafana/grafana/pull/95906), [@aangelisc](https://github.com/aangelisc) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95229](https://github.com/grafana/grafana/pull/95229), [@kalleep](https://github.com/kalleep) +- **Prometheus:** Fix interpolating adhoc filters with template variables [#95986](https://github.com/grafana/grafana/pull/95986), [@cazeaux](https://github.com/cazeaux) +- **ServerLock:** Fix pg concurrency/locking issue [#95932](https://github.com/grafana/grafana/pull/95932), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94971](https://github.com/grafana/grafana/pull/94971), [@samjewell](https://github.com/samjewell) + + # 10.4.13 (2024-11-19) From 1b0cc60d65e644cddb5c87dfb11de54c8657a79d Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:26:49 +0100 Subject: [PATCH 31/60] Alerting: Move evaluation outside folder section, and move labels in instead (#95121) * Move evaluation outside folder section, and move labels in instead * rename file * update translations * refactor * rename file and component * refactor * fix test * refactor * rename files and components * update translations * fix style * update translations * Use useAppNotification for toasts * update label when group can not be selected yet * update translations * update some texts and add comment * update translations * remove duplicated code * fix typo * update texts and translations * rename FolderWithoutGroup to FolderSelector * restore wrong updates * restore wrong updates * translations and remove GroupAndFolder component * address review comments * remove container * address review comments * address review comments * prettier * prettier --- .betterer.results | 17 +- .../rule-editor/AnnotationsStep.tsx | 6 +- .../components/rule-editor/FolderAndGroup.tsx | 489 ------------ .../components/rule-editor/FolderSelector.tsx | 186 +++++ .../rule-editor/GrafanaEvaluationBehavior.tsx | 730 ++++++++++++------ .../GrafanaFolderAndLabelsStep.tsx | 73 ++ .../rule-editor/NotificationsStep.tsx | 45 +- .../alert-rule-form/AlertRuleForm.tsx | 22 +- .../alert-rule-form/ModifyExportRuleForm.tsx | 9 +- .../rule-editor/labels/LabelsEditorModal.tsx | 9 +- .../rule-editor/labels/LabelsField.tsx | 9 +- .../NotificationPreview.tsx | 7 +- .../rules/EditRuleGroupModal.test.tsx | 10 +- .../components/rules/EditRuleGroupModal.tsx | 2 +- .../unified/components/rules/RulesGroup.tsx | 6 +- .../alerting/unified/types/rule-form.ts | 3 + .../alerting/unified/utils/rule-form.ts | 3 +- .../features/alerting/unified/utils/rules.ts | 8 +- public/locales/en-US/grafana.json | 88 ++- public/locales/pseudo-LOCALE/grafana.json | 88 ++- 20 files changed, 954 insertions(+), 856 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx diff --git a/.betterer.results b/.betterer.results index ff134e57eaa..152968f9869 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1615,21 +1615,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "13"] + "public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx index 31c3d1318d3..52039ffc562 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx @@ -11,6 +11,7 @@ import { t, Trans } from 'app/core/internationalization'; import { DashboardModel } from '../../../../dashboard/state'; import { RuleFormValues } from '../../types/rule-form'; import { Annotation, annotationLabels } from '../../utils/constants'; +import { isGrafanaManagedRuleByType } from '../../utils/rules'; import AnnotationHeaderField from './AnnotationHeaderField'; import DashboardAnnotationField from './DashboardAnnotationField'; @@ -31,6 +32,7 @@ const AnnotationsStep = () => { setValue, } = useFormContext(); const annotations = watch('annotations'); + const type = watch('type'); const { fields, append, remove } = useFieldArray({ control, name: 'annotations' }); @@ -104,10 +106,12 @@ const AnnotationsStep = () => { ); } + // when using Grafana managed rules, the annotations step is the 6th step, as we have an additional step for the configure labels and notifications + const step = isGrafanaManagedRuleByType(type) ? 6 : 5; return ( { - // fetch the ruler rules from the database so we can figure out what other "groups" are already defined - // for our folders - const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = - alertRuleApi.endpoints.rulerNamespace.useQuery( - { - namespace: folderUid, - rulerConfig: GRAFANA_RULER_CONFIG, - }, - { - skip: !folderUid, - refetchOnMountOrArgChange: true, - } - ); - - // There should be only one entry in the rulerNamespace object - // However it uses folder name as key, so to avoid fetching folder name, we use Object.values - const groupOptions = useMemo(() => { - if (!rulerNamespace) { - // still waiting for namespace information to be fetched - return []; - } - - const folderGroups = Object.values(rulerNamespace).flat() ?? []; - - return folderGroups - .map>((group) => { - const isProvisioned = isProvisionedGroup(group); - return { - label: group.name, - value: group.name, - description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, - // we include provisioned folders, but disable the option to select them - isDisabled: !enableProvisionedGroups ? isProvisioned : false, - isProvisioned: isProvisioned, - }; - }) - - .sort(sortByLabel); - }, [rulerNamespace, enableProvisionedGroups]); - - return { groupOptions, loading: isLoadingRulerNamespace }; -}; - -const isProvisionedGroup = (group: RulerRuleGroupDTO) => { - return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true); -}; - -const sortByLabel = (a: SelectableValue, b: SelectableValue) => { - return a.label?.localeCompare(b.label ?? '') || 0; -}; - -const findGroupMatchingLabel = (group: SelectableValue, query: string) => { - return group.label?.toLowerCase().includes(query.toLowerCase()); -}; - -export function FolderAndGroup({ - groupfoldersForGrafana, - enableProvisionedGroups, -}: { - groupfoldersForGrafana?: RulerRulesConfigDTO | null; - enableProvisionedGroups: boolean; -}) { - const { - formState: { errors }, - watch, - setValue, - control, - } = useFormContext(); - - const styles = useStyles2(getStyles); - - const [folder, group, type] = watch(['folder', 'group', 'type']); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; - - const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); - - const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); - - const onOpenFolderCreationModal = () => setIsCreatingFolder(true); - const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true); - - const handleFolderCreation = (folder: Folder) => { - resetGroup(); - setValue('folder', folder); - setIsCreatingFolder(false); - }; - - const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => { - setValue('group', groupName); - setValue('evaluateEvery', evaluationInterval); - setIsCreatingEvaluationGroup(false); - }; - - const resetGroup = useCallback(() => { - setValue('group', ''); - }, [setValue]); - - const getOptions = useCallback( - async (query: string) => { - const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions; - return take(results, MAX_GROUP_RESULTS); - }, - [groupOptions] - ); - - const debouncedSearch = useMemo(() => { - return debounce(getOptions, 300, { leading: true }); - }, [getOptions]); - - const defaultGroupValue = group ? { value: group, label: group } : undefined; - - const evaluationDesc = isGrafanaRecordingRule - ? t('alerting.folderAndGroup.evaluation.text.recording', 'Define how often the recording rule is evaluated.') - : t('alerting.folderAndGroup.evaluation.text.alerting', 'Define how often the alert rule is evaluated.'); - - return ( -
- - { - - Folder - - } - className={styles.formInput} - error={errors.folder?.message} - data-testid="folder-picker" - > - - {(!isCreatingFolder && ( - <> - ( -
- { - if (uid && title) { - setValue('folder', { title, uid }); - } else { - setValue('folder', undefined); - } - - resetGroup(); - }} - /> -
- )} - name="folder" - rules={{ - required: { value: true, message: 'Select a folder' }, - }} - /> - or - - - )) ||
Creating new folder...
} -
-
- } - {isCreatingFolder && ( - setIsCreatingFolder(false)} /> - )} -
- - -
- - ( - { - field.onChange(group.label ?? ''); - }} - isLoading={loading} - invalid={Boolean(folder) && !group && Boolean(fieldState.error)} - loadOptions={debouncedSearch} - cacheOptions - loadingMessage={'Loading groups...'} - defaultValue={defaultGroupValue} - defaultOptions={groupOptions} - getOptionLabel={(option: SelectableValue) => ( -
- {option.label} - {option.isProvisioned && ( - <> - {' '} - - - )} -
- )} - placeholder={'Select an evaluation group...'} - /> - )} - name="group" - control={control} - rules={{ - required: { value: true, message: 'Must enter a group name' }, - }} - /> -
-
- - or - - - {isCreatingEvaluationGroup && ( - setIsCreatingEvaluationGroup(false)} - groupfoldersForGrafana={groupfoldersForGrafana} - /> - )} -
-
- ); -} - -function FolderCreationModal({ - onClose, - onCreate, -}: { - onClose: () => void; - onCreate: (folder: Folder) => void; -}): React.ReactElement { - const styles = useStyles2(getStyles); - - const notifyApp = useAppNotification(); - const [title, setTitle] = useState(''); - const [createFolder] = useNewFolderMutation(); - - const onSubmit = async () => { - const { data, error } = await createFolder({ title }); - - if (error) { - notifyApp.error('Failed to create folder'); - } else if (data) { - onCreate({ title: data.title, uid: data.uid }); - notifyApp.success('Folder created'); - } - }; - - return ( - -
Create a new folder to store your rule
- - - Folder name}> - setTitle(e.currentTarget.value)} - className={styles.formInput} - /> - - - - - - - -
- ); -} - -function EvaluationGroupCreationModal({ - onClose, - onCreate, - groupfoldersForGrafana, -}: { - onClose: () => void; - onCreate: (group: string, evaluationInterval: string) => void; - groupfoldersForGrafana?: RulerRulesConfigDTO | null; -}): React.ReactElement { - const styles = useStyles2(getStyles); - const onSubmit = () => { - onCreate(getValues('group'), getValues('evaluateEvery')); - }; - - const { watch } = useFormContext(); - - const evaluateEveryId = 'eval-every-input'; - const evaluationGroupNameId = 'new-eval-group-name'; - const [groupName, folderName, type] = watch(['group', 'folder.title', 'type']); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; - - const groupRules = - (groupfoldersForGrafana && groupfoldersForGrafana[folderName]?.find((g) => g.name === groupName)?.rules) ?? []; - - const onCancel = () => { - onClose(); - }; - - const formAPI = useForm({ - defaultValues: { group: '', evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL }, - mode: 'onChange', - shouldFocusError: true, - }); - - const { register, handleSubmit, formState, setValue, getValues, watch: watchGroupFormValues } = formAPI; - const evaluationInterval = watchGroupFormValues('evaluateEvery'); - - const setEvaluationInterval = (interval: string) => { - setValue('evaluateEvery', interval, { shouldValidate: true }); - }; - - const modalTitle = isGrafanaRecordingRule - ? t( - 'alerting.folderAndGroup.evaluation.modal.text.recording', - 'Create a new evaluation group to use for this recording rule.' - ) - : t( - 'alerting.folderAndGroup.evaluation.modal.text.alerting', - 'Create a new evaluation group to use for this alert rule.' - ); - - return ( - -
{modalTitle}
- - -
onSubmit())}> - - Evaluation group name - - } - error={formState.errors.group?.message} - invalid={Boolean(formState.errors.group)} - > - - - - - Evaluation interval - - } - invalid={Boolean(formState.errors.evaluateEvery)} - > - (groupRules) - )} - /> - - - - - - - - - -
-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - display: 'flex', - flexDirection: 'column', - alignItems: 'baseline', - maxWidth: `${theme.breakpoints.values.lg}px`, - justifyContent: 'space-between', - }), - formInput: css({ - flexGrow: 1, - }), - modal: css({ - width: `${theme.breakpoints.values.sm}px`, - }), - modalTitle: css({ - color: theme.colors.text.secondary, - marginBottom: theme.spacing(2), - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx new file mode 100644 index 00000000000..e81d18e251c --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx @@ -0,0 +1,186 @@ +import { css } from '@emotion/css'; +import * as React from 'react'; +import { useCallback, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Field, Input, Label, Modal, Stack, Text, useStyles2 } from '@grafana/ui'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/services/context_srv'; +import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { AccessControlAction } from 'app/types'; + +import { Trans } from '../../../../../core/internationalization/index'; +import { Folder, RuleFormValues } from '../../types/rule-form'; + +export function FolderSelector() { + const { + formState: { errors }, + setValue, + watch, + } = useFormContext(); + + const resetGroup = useCallback(() => { + setValue('group', ''); + }, [setValue]); + + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const folder = watch('folder'); + + const onOpenFolderCreationModal = () => setIsCreatingFolder(true); + + const handleFolderCreation = (folder: Folder) => { + resetGroup(); + setValue('folder', folder); + setIsCreatingFolder(false); + }; + + return ( + <> + + { + + Folder + + } + error={errors.folder?.message} + data-testid="folder-picker" + > + + {(!isCreatingFolder && ( + <> + ( +
+ { + if (uid && title) { + setValue('folder', { title, uid }); + } else { + setValue('folder', undefined); + } + + resetGroup(); + }} + /> +
+ )} + name="folder" + rules={{ + required: { value: true, message: 'Select a folder' }, + }} + /> + + or + + + + )) || ( +
+ Creating new folder + {'...'} +
+ )} +
+
+ } +
+ + {isCreatingFolder && ( + setIsCreatingFolder(false)} /> + )} + + ); +} + +function FolderCreationModal({ + onClose, + onCreate, +}: { + onClose: () => void; + onCreate: (folder: Folder) => void; +}): React.ReactElement { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + const [title, setTitle] = useState(''); + const [createFolder] = useNewFolderMutation(); + + const onSubmit = async () => { + const { data, error } = await createFolder({ title }); + + if (error) { + notifyApp.error('Failed to create folder'); + } else if (data) { + onCreate({ title: data.title, uid: data.uid }); + notifyApp.success('Folder created'); + } + }; + + return ( + + + + + Create a new folder to store your alert rule in. + + + +
+ + Folder name + + } + > + setTitle(e.currentTarget.value)} + /> + + + + + + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + modal: css({ + width: `${theme.breakpoints.values.sm}px`, + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 24f13f06bbc..95612dc75f7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -1,29 +1,113 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useState } from 'react'; -import { Controller, RegisterOptions, useFormContext } from 'react-hook-form'; +import { debounce, take, uniqueId } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Controller, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui'; -import { Trans, t } from 'app/core/internationalization'; -import { isGrafanaAlertingRuleByType, isGrafanaRecordingRuleByType } from 'app/features/alerting/unified/utils/rules'; +import { selectors } from '@grafana/e2e-selectors'; +import { + AsyncSelect, + Box, + Button, + Field, + Icon, + IconButton, + Input, + Label, + Modal, + Stack, + Switch, + Text, + Tooltip, + useStyles2, +} from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; -import { LogMessages, logInfo } from '../../Analytics'; +import { logInfo, LogMessages } from '../../Analytics'; +import { alertRuleApi } from '../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; +import { + isGrafanaAlertingRuleByType, + isGrafanaManagedRuleByType, + isGrafanaRecordingRuleByType, + isGrafanaRulerRule, +} from '../../utils/rules'; import { parsePrometheusDuration } from '../../utils/time'; import { CollapseToggle } from '../CollapseToggle'; -import { EditCloudGroupModal } from '../rules/EditRuleGroupModal'; +import { ProvisioningBadge } from '../Provisioning'; +import { EditRuleGroupModal, evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; -import { FolderAndGroup, useFolderGroupOptions } from './FolderAndGroup'; +import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { NeedHelpInfo } from './NeedHelpInfo'; import { PendingPeriodQuickPick } from './PendingPeriodQuickPick'; import { RuleEditorSection } from './RuleEditorSection'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds +export const MAX_GROUP_RESULTS = 1000; + +export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { + // fetch the ruler rules from the database so we can figure out what other "groups" are already defined + // for our folders + const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = + alertRuleApi.endpoints.rulerNamespace.useQuery( + { + namespace: folderUid, + rulerConfig: GRAFANA_RULER_CONFIG, + }, + { + skip: !folderUid, + refetchOnMountOrArgChange: true, + } + ); + + // There should be only one entry in the rulerNamespace object + // However it uses folder name as key, so to avoid fetching folder name, we use Object.values + const groupOptions = useMemo(() => { + if (!rulerNamespace) { + // still waiting for namespace information to be fetched + return []; + } + + const folderGroups = Object.values(rulerNamespace).flat() ?? []; + + return folderGroups + .map>((group) => { + const isProvisioned = isProvisionedGroup(group); + return { + label: group.name, + value: group.name, + description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, + // we include provisioned folders, but disable the option to select them + isDisabled: !enableProvisionedGroups ? isProvisioned : false, + isProvisioned: isProvisioned, + }; + }) + + .sort(sortByLabel); + }, [rulerNamespace, enableProvisionedGroups]); + + return { groupOptions, loading: isLoadingRulerNamespace }; +}; + +const isProvisionedGroup = (group: RulerRuleGroupDTO) => { + return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true); +}; + +const sortByLabel = (a: SelectableValue, b: SelectableValue) => { + return a.label?.localeCompare(b.label ?? '') || 0; +}; + +const findGroupMatchingLabel = (group: SelectableValue, query: string) => { + return group.label?.toLowerCase().includes(query.toLowerCase()); +}; const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluateFor: string }> => ({ required: { @@ -49,7 +133,7 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate return millisFor >= millisEvery ? true : t( - 'alert-rule-form.evaluation-behaviour-for.validation', + 'alerting.rule-form.evaluation-behaviour-for.validation', 'Pending period must be greater than or equal to the evaluation interval.' ); } catch (err) { @@ -60,7 +144,7 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate } catch (error) { return error instanceof Error ? error.message - : t('alert-rule-form.evaluation-behaviour-for.error-parsing', 'Failed to parse duration'); + : t('alerting.rule-form.evaluation-behaviour-for.error-parsing', 'Failed to parse duration'); } }, }); @@ -75,29 +159,50 @@ const useIsNewGroup = (folder: string, group: string) => { return !groupIsInGroupOptions(group); }; -function FolderGroupAndEvaluationInterval({ +export function GrafanaEvaluationBehaviorStep({ evaluateEvery, setEvaluateEvery, + existing, enableProvisionedGroups, }: { evaluateEvery: string; setEvaluateEvery: (value: string) => void; + existing: boolean; enableProvisionedGroups: boolean; }) { const styles = useStyles2(getStyles); - const { watch, setValue, getValues } = useFormContext(); - const [isEditingGroup, setIsEditingGroup] = useState(false); + const [showErrorHandling, setShowErrorHandling] = useState(false); - const [groupName, folderUid, folderName] = watch(['group', 'folder.uid', 'folder.title']); + const { + watch, + setValue, + getValues, + formState: { errors }, + control, + } = useFormContext(); + + const [folder, group, type, isPaused, folderUid, folderName] = watch([ + 'folder', + 'group', + 'type', + 'isPaused', + 'folder.uid', + 'folder.title', + ]); + + const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type); + const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(type); + const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); + const [isEditingGroup, setIsEditingGroup] = useState(false); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid); - const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName); + const existingGroup = existingNamespace?.groups.find((g) => g.name === group); - const isNewGroup = useIsNewGroup(folderUid ?? '', groupName); + const isNewGroup = useIsNewGroup(folderUid ?? '', group); useEffect(() => { if (!isNewGroup && existingGroup?.interval) { @@ -114,204 +219,166 @@ function FolderGroupAndEvaluationInterval({ const onOpenEditGroupModal = () => setIsEditingGroup(true); - const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName; + const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !group; const emptyNamespace: CombinedRuleNamespace = { name: folderName, rulesSource: GRAFANA_RULES_SOURCE_NAME, groups: [], }; - const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [], totals: {} }; + const emptyGroup: CombinedRuleGroup = { name: group, interval: evaluateEvery, rules: [], totals: {} }; - return ( -
- - {folderName && isEditingGroup && ( - closeEditGroupModal()} - intervalEditOnly - hideFolder={true} - /> - )} - {folderName && groupName && ( -
- -
- - {getValues('group') && getValues('evaluateEvery') && ( - - - All rules in the selected group are evaluated every {{ evaluateEvery }}. - - {!isNewGroup && ( - - )} - - )} - -
-
-
- )} -
- ); -} + const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); -function ForInput({ evaluateEvery }: { evaluateEvery: string }) { - const styles = useStyles2(getStyles); - const { - register, - formState: { errors }, - setValue, - watch, - } = useFormContext(); - - const evaluateForId = 'eval-for-input'; - const currentPendingPeriod = watch('evaluateFor'); - - const setPendingPeriod = (pendingPeriod: string) => { - setValue('evaluateFor', pendingPeriod); + const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => { + setValue('group', groupName); + setValue('evaluateEvery', evaluationInterval); + setIsCreatingEvaluationGroup(false); }; - return ( - - - Pending period - - } - className={styles.inlineField} - error={errors.evaluateFor?.message} - invalid={Boolean(errors.evaluateFor?.message) ? true : undefined} - validationMessageHorizontalOverflow={true} - > - - - - + const getOptions = useCallback( + async (query: string) => { + const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions; + return take(results, MAX_GROUP_RESULTS); + }, + [groupOptions] ); -} -function NeedHelpInfoForConfigureNoDataError() { - const docsLink = - 'https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling'; + const debouncedSearch = useMemo(() => { + return debounce(getOptions, 300, { leading: true }); + }, [getOptions]); - return ( - - - - Define the alert behavior when the evaluation fails or the query returns no data. - - - - - ); -} - -function getDescription(isGrafanaRecordingRule: boolean) { - const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/'; - - return ( - - - {isGrafanaRecordingRule ? ( - - Define how the recording rule is evaluated. - - ) : ( - - Define how the alert rule is evaluated. - - )} - - -

- - Evaluation groups are containers for evaluating alert and recording rules. - -

-

- - An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within - the same evaluation group are evaluated over the same evaluation interval. - -

-

- - Pending period specifies how long the threshold condition must be met before the alert starts firing. - This option helps prevent alerts from being triggered by temporary issues. - -

- - } - externalLink={docsLink} - linkText={`Read about evaluation and alert states`} - title="Alert rule evaluation" - /> -
- ); -} - -export function GrafanaEvaluationBehavior({ - evaluateEvery, - setEvaluateEvery, - existing, - enableProvisionedGroups, -}: { - evaluateEvery: string; - setEvaluateEvery: (value: string) => void; - existing: boolean; - enableProvisionedGroups: boolean; -}) { - const styles = useStyles2(getStyles); - const [showErrorHandling, setShowErrorHandling] = useState(false); - - const { watch, setValue } = useFormContext(); - - const isPaused = watch('isPaused'); - const type = watch('type'); - - const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; + const defaultGroupValue = group ? { value: group, label: group } : undefined; const pauseContentText = isGrafanaRecordingRule - ? t('alert-rule-form.pause.recording', 'Turn on to pause evaluation for this recording rule.') - : t('alert-rule-form.pause.alerting', 'Turn on to pause evaluation for this alert rule.'); + ? t('alerting.rule-form.evaluation.pause.recording', 'Turn on to pause evaluation for this recording rule.') + : t('alerting.rule-form.evaluation.pause.alerting', 'Turn on to pause evaluation for this alert rule.'); + + const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true); + + const step = isGrafanaManagedRuleByType(type) ? 4 : 3; + const label = + isGrafanaManagedRuleByType(type) && !folder + ? t( + 'alerting.rule-form.evaluation.select-folder-before', + 'Select a folder before setting evaluation group and interval' + ) + : t('alerting.rule-form.evaluation.evaluation-group-and-interval', 'Evaluation group and interval'); return ( // TODO remove "and alert condition" for recording rules - + - + +
+ + ( + { + field.onChange(group.label ?? ''); + }} + isLoading={loading} + invalid={Boolean(folder) && !group && Boolean(fieldState.error)} + loadOptions={debouncedSearch} + cacheOptions + loadingMessage={'Loading groups...'} + defaultValue={defaultGroupValue} + defaultOptions={groupOptions} + getOptionLabel={(option: SelectableValue) => ( +
+ {option.label} + {option.isProvisioned && ( + <> + {' '} + + + )} +
+ )} + placeholder={'Select an evaluation group...'} + /> + )} + name="group" + control={control} + rules={{ + required: { value: true, message: 'Must enter a group name' }, + }} + /> +
+
+ + or + + + {isCreatingEvaluationGroup && ( + setIsCreatingEvaluationGroup(false)} + groupfoldersForGrafana={groupfoldersForGrafana?.result} + /> + )} +
+ + {folderName && isEditingGroup && ( + closeEditGroupModal()} + intervalEditOnly + hideFolder={true} + /> + )} + {folderName && group && ( +
+ +
+ + {getValues('group') && getValues('evaluateEvery') && ( + + + All rules in the selected group are evaluated every {{ evaluateEvery }}. + + {!isNewGroup && ( + + )} + + )} + +
+
+
+ )} {/* Show the pending period input only for Grafana alerting rules */} {isGrafanaAlertingRule && } @@ -328,7 +395,7 @@ export function GrafanaEvaluationBehavior({ value={Boolean(isPaused)} /> + } + className={styles.inlineField} + error={errors.evaluateFor?.message} + invalid={Boolean(errors.evaluateFor?.message) ? true : undefined} + validationMessageHorizontalOverflow={true} + > + +
+ + + ); +} + +function NeedHelpInfoForConfigureNoDataError() { + const docsLink = + 'https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling'; + + return ( + + + + Define the alert behavior when the evaluation fails or the query returns no data. + + + + + ); +} + +function getDescription(isGrafanaRecordingRule: boolean) { + const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/'; + + return ( + + + {isGrafanaRecordingRule ? ( + + Define how the recording rule is evaluated. + + ) : ( + + Define how the alert rule is evaluated. + + )} + + +

+ + Evaluation groups are containers for evaluating alert and recording rules. + +

+

+ + An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within + the same evaluation group are evaluated over the same evaluation interval. + +

+

+ + Pending period specifies how long the threshold condition must be met before the alert starts firing. + This option helps prevent alerts from being triggered by temporary issues. + +

+ + } + externalLink={docsLink} + linkText={`Read about evaluation and alert states`} + title="Alert rule evaluation" + /> +
+ ); +} + const getStyles = (theme: GrafanaTheme2) => ({ inlineField: css({ marginBottom: 0, }), - evaluateLabel: css({ - marginRight: theme.spacing(1), - }), evaluationContainer: css({ color: theme.colors.text.secondary, maxWidth: `${theme.breakpoints.values.sm}px`, fontSize: theme.typography.size.sm, }), - intervalChangedLabel: css({ - marginBottom: theme.spacing(1), - }), - warningIcon: css({ - justifySelf: 'center', - marginRight: theme.spacing(1), - color: theme.colors.warning.text, - }), infoIcon: css({ marginLeft: '10px', }), - warningMessage: css({ - color: theme.colors.warning.text, - }), - bold: css({ - fontWeight: 'bold', - }), - alignInterval: css({ - marginTop: theme.spacing(1), - marginLeft: `-${theme.spacing(1)}`, - }), marginTop: css({ marginTop: theme.spacing(1), }), @@ -429,4 +711,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ cursor: 'pointer', fontSize: theme.typography.bodySmall.fontSize, }), + formInput: css({ + flexGrow: 1, + }), + modal: css({ + width: `${theme.breakpoints.values.sm}px`, + }), + modalTitle: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx new file mode 100644 index 00000000000..e91ec0cb697 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Stack, Text } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { KBObjectArray, RuleFormValues } from '../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; + +import { FolderSelector } from './FolderSelector'; +import { NeedHelpInfo } from './NeedHelpInfo'; +import { RuleEditorSection } from './RuleEditorSection'; +import { LabelsEditorModal } from './labels/LabelsEditorModal'; +import { LabelsFieldInForm } from './labels/LabelsFieldInForm'; + +/** Precondition: rule is Grafana managed. + */ +export function GrafanaFolderAndLabelsStep() { + const { setValue, getValues } = useFormContext(); + const [showLabelsEditor, setShowLabelsEditor] = useState(false); + + function onCloseLabelsEditor(labelsToUpdate?: KBObjectArray) { + if (labelsToUpdate) { + setValue('labels', labelsToUpdate); + } + setShowLabelsEditor(false); + } + + function SectionDescription() { + return ( + + + + Organize your alert rule with a folder and set of labels. + + + +

+ {t( + 'alerting.rule-form.folders.help-info', + 'Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders.' + )} +

+

+ {t( + 'alerting.rule-form.labels.help-info', + 'Labels are used to differentiate an alert from all other alerts.You can use them for searching, silencing, and routing notifications.' + )} +

+ + } + /> +
+ ); + } + + return ( + }> + + + setShowLabelsEditor(true)} /> + + + + ); +} 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 d52f12fd042..5e63b3afed5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -8,9 +8,9 @@ import { Icon, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; -import { RuleFormType, RuleFormValues } from '../../types/rule-form'; +import { KBObjectArray, RuleFormType, RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { isRecordingRuleByType } from '../../utils/rules'; +import { isGrafanaManagedRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules'; import { NeedHelpInfo } from './NeedHelpInfo'; import { RuleEditorSection } from './RuleEditorSection'; @@ -45,6 +45,7 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { const [showLabelsEditor, setShowLabelsEditor] = useState(false); const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; + const isGrafanaManaged = isGrafanaManagedRuleByType(type); const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; const shouldRenderpreview = type === RuleFormType.grafana; const hasInternalAlertmanagerEnabled = useHasInternalAlertmanagerEnabled(); @@ -52,25 +53,29 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { const shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled && hasInternalAlertmanagerEnabled; - function onCloseLabelsEditor( - labelsToUpdate?: Array<{ - key: string; - value: string; - }> - ) { + function onCloseLabelsEditor(labelsToUpdate?: KBObjectArray) { if (labelsToUpdate) { setValue('labels', labelsToUpdate); } setShowLabelsEditor(false); } - if (!type) { + + if (isGrafanaRecordingRuleByType(type)) { return null; } + const step = !isGrafanaManaged ? 4 : 5; + + const title = isRecordingRuleByType(type) + ? 'Add labels' + : isGrafanaManaged + ? 'Configure notifications' + : 'Configure labels and notifications'; + return ( {isRecordingRuleByType(type) ? ( @@ -88,13 +93,17 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { } fullWidth > - setShowLabelsEditor(true)} /> - + {!isGrafanaManaged && ( + <> + setShowLabelsEditor(true)} /> + + + )} {shouldAllowSimplifiedRouting && (
Notifications diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 447f538136e..30f21ce9017 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -13,6 +13,8 @@ import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedR import { getRuleGroupLocationFromFormValues, getRuleGroupLocationFromRuleWithLocation, + isCloudAlertingRuleByType, + isCloudRecordingRuleByType, isCloudRulerRule, isGrafanaManagedRuleByType, isGrafanaRulerRule, @@ -60,7 +62,8 @@ import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior'; -import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; +import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; +import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep'; import { NotificationsStep } from '../NotificationsStep'; import { RecordingRulesNameSpaceAndGroupStep } from '../RecordingRulesNameSpaceAndGroupStep'; import { RuleInspector } from '../RuleInspector'; @@ -296,23 +299,24 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {showDataSourceDependantStep && ( <> {/* Step 3 */} + {isGrafanaManagedRuleByType(type) && } + + {isCloudAlertingRuleByType(type) && } + + {isCloudRecordingRuleByType(type) && } + + {/* Step 4 & 5 & 6*/} {isGrafanaManagedRuleByType(type) && ( - )} - - {type === RuleFormType.cloudAlerting && } - - {type === RuleFormType.cloudRecording && } - - {/* Step 4 & 5 */} {/* Notifications step*/} - {/* Annotations only for cloud and Grafana */} + {/* Annotations only for alerting rules */} {!isRecordingRuleByType(type) && } )} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index e35f6da5cf2..a4bf8e6b4ba 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -24,7 +24,8 @@ import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; import { ExportFormats, allGrafanaExportProviders } from '../../export/providers'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; -import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; +import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; +import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep'; import { NotificationsStep } from '../NotificationsStep'; import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep'; @@ -90,15 +91,15 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor {/* Step 2 */} {/* Step 3-4-5 */} + - - - {/* Step 4 & 5 */} {/* Notifications step*/} {/* Annotations only for cloud and Grafana */} diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx index a98ad871b49..e6472fa31cb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx @@ -1,5 +1,7 @@ import { Modal } from '@grafana/ui'; +import { KBObjectArray } from '../../../types/rule-form'; + import { LabelsSubForm } from './LabelsField'; export interface LabelsEditorModalProps { @@ -8,12 +10,7 @@ export interface LabelsEditorModalProps { key: string; value: string; }>; - onClose: ( - labelsToUodate?: Array<{ - key: string; - value: string; - }> - ) => void; + onClose: (labelsToUodate?: KBObjectArray) => void; dataSourceName: string; } export function LabelsEditorModal({ isOpen, onClose, dataSourceName, initialLabels }: LabelsEditorModalProps) { diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 604f7f69157..1335580837d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -9,7 +9,7 @@ import { t } from 'app/core/internationalization'; import { labelsApi } from '../../../api/labelsApi'; import { usePluginBridge } from '../../../hooks/usePluginBridge'; import { SupportedPlugin } from '../../../types/pluginBridges'; -import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; +import { KBObjectArray, RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { isPrivateLabelKey } from '../../../utils/labels'; import { isRecordingRuleByType } from '../../../utils/rules'; import AlertLabelDropdown from '../../AlertLabelDropdown'; @@ -56,12 +56,7 @@ export type LabelsSubformValues = { export interface LabelsSubFormProps { dataSourceName: string; initialLabels: Array<{ key: string; value: string }>; - onClose: ( - labelsToUodate?: Array<{ - key: string; - value: string; - }> - ) => void; + onClose: (labelsToUodate?: KBObjectArray) => void; } export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: LabelsSubFormProps) { diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx index 8ad79d2fe9a..5f42608231a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -8,16 +8,13 @@ import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { Folder } from '../../../types/rule-form'; +import { Folder, KBObjectArray } from '../../../types/rule-form'; import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource'; const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager')); interface NotificationPreviewProps { - customLabels: Array<{ - key: string; - value: string; - }>; + customLabels: KBObjectArray; alertQueries: AlertQuery[]; condition: string | null; folder?: Folder; diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx index 65c65e07685..dcbc646ae03 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx @@ -14,7 +14,7 @@ import { } from '../../mocks'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { EditCloudGroupModal } from './EditRuleGroupModal'; +import { EditRuleGroupModal } from './EditRuleGroupModal'; const ui = { input: { @@ -40,7 +40,7 @@ describe('EditGroupModal', () => { const group = namespace.groups[0]; - render(); + render(); expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); expect(ui.input.group.get()).toHaveAttribute('readonly'); @@ -80,7 +80,7 @@ describe('EditGroupModal component on cloud alert rules', () => { const group = promNs.groups[0]; - render(); + render(); expect(await ui.input.namespace.find()).toHaveValue('prometheus-ns'); expect(ui.input.namespace.get()).not.toHaveAttribute('readonly'); @@ -99,7 +99,7 @@ describe('EditGroupModal component on cloud alert rules', () => { const group = promNs.groups[0]; - render(); + render(); expect(ui.table.query()).not.toBeInTheDocument(); expect(await ui.noRulesText.find()).toBeInTheDocument(); }); @@ -133,7 +133,7 @@ describe('EditGroupModal component on grafana-managed alert rules', () => { const grafanaGroup1 = grafanaNamespace.groups[0]; const renderWithGrafanaGroup = () => - render(); + render(); it('Should show alert table', async () => { renderWithGrafanaGroup(); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 2ff9d57baaf..f6c4a116005 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -178,7 +178,7 @@ export interface ModalProps { hideFolder?: boolean; } -export function EditCloudGroupModal(props: ModalProps): React.ReactElement { +export function EditRuleGroupModal(props: ModalProps): React.ReactElement { const { namespace, group, onClose, intervalEditOnly, folderUid } = props; const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index f8a1f6d7431..5987704636b 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; -import { LogMessages, logInfo } from '../../Analytics'; +import { logInfo, LogMessages } from '../../Analytics'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; @@ -23,7 +23,7 @@ import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter'; import { decodeGrafanaNamespace } from '../expressions/util'; import { ActionIcon } from './ActionIcon'; -import { EditCloudGroupModal } from './EditRuleGroupModal'; +import { EditRuleGroupModal } from './EditRuleGroupModal'; import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; import { RuleGroupStats } from './RuleStats'; import { RulesTable } from './RulesTable'; @@ -275,7 +275,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: /> )} {isEditingGroup && ( - closeEditModal()} diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 1ff9c721e87..8314961ba5d 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -27,6 +27,9 @@ export interface SimplifiedEditor { simplifiedQueryEditor: boolean; } +export type KVObject = { key: string; value: string }; +export type KBObjectArray = KVObject[]; + export interface RuleFormValues { // common name: string; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 2154b66e026..f3be44d152b 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -40,12 +40,11 @@ import { RulerRuleDTO, } from 'app/types/unified-alerting-dto'; -type KVObject = { key: string; value: string }; - import { EvalFunction } from '../../state/alertDef'; import { AlertManagerManualRouting, ContactPoint, + KVObject, RuleFormType, RuleFormValues, SimplifiedEditor, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index fd372acdda4..ad6ffc15a3f 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -417,7 +417,7 @@ export function isGrafanaAlertingRuleByType(type?: RuleFormType) { return type === RuleFormType.grafana; } -export function isGrafanaRecordingRuleByType(type: RuleFormType) { +export function isGrafanaRecordingRuleByType(type?: RuleFormType) { return type === RuleFormType.grafanaRecording; } @@ -429,14 +429,14 @@ export function isCloudRecordingRuleByType(type?: RuleFormType) { return type === RuleFormType.cloudRecording; } -export function isGrafanaManagedRuleByType(type: RuleFormType) { +export function isGrafanaManagedRuleByType(type?: RuleFormType) { return isGrafanaAlertingRuleByType(type) || isGrafanaRecordingRuleByType(type); } -export function isRecordingRuleByType(type: RuleFormType) { +export function isRecordingRuleByType(type?: RuleFormType) { return isGrafanaRecordingRuleByType(type) || isCloudRecordingRuleByType(type); } -export function isDataSourceManagedRuleByType(type: RuleFormType) { +export function isDataSourceManagedRuleByType(type?: RuleFormType) { return isCloudAlertingRuleByType(type) || isCloudRecordingRuleByType(type); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6ddc36f82b7..e2fb153b789 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "Define the alert behavior when the evaluation fails or the query returns no data." - }, - "pending-period": "Pending period" - }, - "evaluation-behaviour-description1": "Evaluation groups are containers for evaluating alert and recording rules.", - "evaluation-behaviour-description2": "An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within the same evaluation group are evaluated over the same evaluation interval.", - "evaluation-behaviour-description3": "Pending period specifies how long the threshold condition must be met before the alert starts firing. This option helps prevent alerts from being triggered by temporary issues.", - "evaluation-behaviour-for": { - "error-parsing": "Failed to parse duration", - "validation": "Pending period must be greater than or equal to the evaluation interval." - }, - "evaluation-behaviour-group": { - "text": "All rules in the selected group are evaluated every {{evaluateEvery}}." - }, - "pause": { - "alerting": "Turn on to pause evaluation for this alert rule.", - "label": "Pause evaluation", - "recording": "Turn on to pause evaluation for this recording rule." - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "Define how the alert rule is evaluated." - } - } - }, "alert-rules": { "firing-for": "Firing for", "next-evaluation": "Next evaluation", @@ -177,10 +147,6 @@ "alerting": "Create a new evaluation group to use for this alert rule.", "recording": "Create a new evaluation group to use for this recording rule." } - }, - "text": { - "alerting": "Define how often the alert rule is evaluated.", - "recording": "Define how often the recording rule is evaluated." } } }, @@ -266,6 +232,60 @@ "preview": "Preview", "previewCondition": "Preview alert rule condition" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "Evaluation group and interval", + "group": { + "cancel": "Cancel", + "create": "Create", + "interval": "Evaluation interval" + }, + "group-name": "Evaluation group name", + "group-text": "All rules in the selected group are evaluated every {{evaluateEvery}}.", + "new-group": "New evaluation group", + "pause": { + "alerting": "Turn on to pause evaluation for this alert rule.", + "recording": "Turn on to pause evaluation for this recording rule." + }, + "select-folder-before": "Select a folder before setting evaluation group and interval" + }, + "evaluation-behaviour": { + "description": { + "text": "Define how the alert rule is evaluated." + }, + "info-help": { + "text": "Define the alert behavior when the evaluation fails or the query returns no data." + }, + "pending-period": "Pending period" + }, + "evaluation-behaviour-description1": "Evaluation groups are containers for evaluating alert and recording rules.", + "evaluation-behaviour-description2": "An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within the same evaluation group are evaluated over the same evaluation interval.", + "evaluation-behaviour-description3": "Pending period specifies how long the threshold condition must be met before the alert starts firing. This option helps prevent alerts from being triggered by temporary issues.", + "evaluation-behaviour-for": { + "error-parsing": "Failed to parse duration", + "validation": "Pending period must be greater than or equal to the evaluation interval." + }, + "folder": { + "cancel": "Cancel", + "create": "Create", + "create-folder": "Create a new folder to store your alert rule in.", + "creating-new-folder": "Creating new folder", + "label": "Folder", + "name": "Folder name", + "new-folder": "New folder", + "new-folder-or": "or" + }, + "folder-and-labels": "Organize your alert rule with a folder and set of labels.", + "folders": { + "help-info": "Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders." + }, + "labels": { + "help-info": "Labels are used to differentiate an alert from all other alerts.You can use them for searching, silencing, and routing notifications." + }, + "pause": { + "label": "Pause evaluation" + } + }, "rule-groups": { "delete": { "success": "Successfully deleted rule group" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b24be9678e2..a471dce121f 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "Đęƒįʼnę ŧĥę äľęřŧ þęĥävįőř ŵĥęʼn ŧĥę ęväľūäŧįőʼn ƒäįľş őř ŧĥę qūęřy řęŧūřʼnş ʼnő đäŧä." - }, - "pending-period": "Pęʼnđįʼnģ pęřįőđ" - }, - "evaluation-behaviour-description1": "Ēväľūäŧįőʼn ģřőūpş äřę čőʼnŧäįʼnęřş ƒőř ęväľūäŧįʼnģ äľęřŧ äʼnđ řęčőřđįʼnģ řūľęş.", - "evaluation-behaviour-description2": "Åʼn ęväľūäŧįőʼn ģřőūp đęƒįʼnęş äʼn ęväľūäŧįőʼn įʼnŧęřväľ - ĥőŵ őƒŧęʼn ä řūľę įş ęväľūäŧęđ. Åľęřŧ řūľęş ŵįŧĥįʼn ŧĥę şämę ęväľūäŧįőʼn ģřőūp äřę ęväľūäŧęđ ővęř ŧĥę şämę ęväľūäŧįőʼn įʼnŧęřväľ.", - "evaluation-behaviour-description3": "Pęʼnđįʼnģ pęřįőđ şpęčįƒįęş ĥőŵ ľőʼnģ ŧĥę ŧĥřęşĥőľđ čőʼnđįŧįőʼn mūşŧ þę męŧ þęƒőřę ŧĥę äľęřŧ şŧäřŧş ƒįřįʼnģ. Ŧĥįş őpŧįőʼn ĥęľpş přęvęʼnŧ äľęřŧş ƒřőm þęįʼnģ ŧřįģģęřęđ þy ŧęmpőřäřy įşşūęş.", - "evaluation-behaviour-for": { - "error-parsing": "Fäįľęđ ŧő päřşę đūřäŧįőʼn", - "validation": "Pęʼnđįʼnģ pęřįőđ mūşŧ þę ģřęäŧęř ŧĥäʼn őř ęqūäľ ŧő ŧĥę ęväľūäŧįőʼn įʼnŧęřväľ." - }, - "evaluation-behaviour-group": { - "text": "Åľľ řūľęş įʼn ŧĥę şęľęčŧęđ ģřőūp äřę ęväľūäŧęđ ęvęřy {{evaluateEvery}}." - }, - "pause": { - "alerting": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş äľęřŧ řūľę.", - "label": "Päūşę ęväľūäŧįőʼn", - "recording": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş řęčőřđįʼnģ řūľę." - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "Đęƒįʼnę ĥőŵ ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ." - } - } - }, "alert-rules": { "firing-for": "Fįřįʼnģ ƒőř", "next-evaluation": "Ńęχŧ ęväľūäŧįőʼn", @@ -177,10 +147,6 @@ "alerting": "Cřęäŧę ä ʼnęŵ ęväľūäŧįőʼn ģřőūp ŧő ūşę ƒőř ŧĥįş äľęřŧ řūľę.", "recording": "Cřęäŧę ä ʼnęŵ ęväľūäŧįőʼn ģřőūp ŧő ūşę ƒőř ŧĥįş řęčőřđįʼnģ řūľę." } - }, - "text": { - "alerting": "Đęƒįʼnę ĥőŵ őƒŧęʼn ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ.", - "recording": "Đęƒįʼnę ĥőŵ őƒŧęʼn ŧĥę řęčőřđįʼnģ řūľę įş ęväľūäŧęđ." } } }, @@ -266,6 +232,60 @@ "preview": "Přęvįęŵ", "previewCondition": "Přęvįęŵ äľęřŧ řūľę čőʼnđįŧįőʼn" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "Ēväľūäŧįőʼn ģřőūp äʼnđ įʼnŧęřväľ", + "group": { + "cancel": "Cäʼnčęľ", + "create": "Cřęäŧę", + "interval": "Ēväľūäŧįőʼn įʼnŧęřväľ" + }, + "group-name": "Ēväľūäŧįőʼn ģřőūp ʼnämę", + "group-text": "Åľľ řūľęş įʼn ŧĥę şęľęčŧęđ ģřőūp äřę ęväľūäŧęđ ęvęřy {{evaluateEvery}}.", + "new-group": "Ńęŵ ęväľūäŧįőʼn ģřőūp", + "pause": { + "alerting": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş äľęřŧ řūľę.", + "recording": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş řęčőřđįʼnģ řūľę." + }, + "select-folder-before": "Ŝęľęčŧ ä ƒőľđęř þęƒőřę şęŧŧįʼnģ ęväľūäŧįőʼn ģřőūp äʼnđ įʼnŧęřväľ" + }, + "evaluation-behaviour": { + "description": { + "text": "Đęƒįʼnę ĥőŵ ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ." + }, + "info-help": { + "text": "Đęƒįʼnę ŧĥę äľęřŧ þęĥävįőř ŵĥęʼn ŧĥę ęväľūäŧįőʼn ƒäįľş őř ŧĥę qūęřy řęŧūřʼnş ʼnő đäŧä." + }, + "pending-period": "Pęʼnđįʼnģ pęřįőđ" + }, + "evaluation-behaviour-description1": "Ēväľūäŧįőʼn ģřőūpş äřę čőʼnŧäįʼnęřş ƒőř ęväľūäŧįʼnģ äľęřŧ äʼnđ řęčőřđįʼnģ řūľęş.", + "evaluation-behaviour-description2": "Åʼn ęväľūäŧįőʼn ģřőūp đęƒįʼnęş äʼn ęväľūäŧįőʼn įʼnŧęřväľ - ĥőŵ őƒŧęʼn ä řūľę įş ęväľūäŧęđ. Åľęřŧ řūľęş ŵįŧĥįʼn ŧĥę şämę ęväľūäŧįőʼn ģřőūp äřę ęväľūäŧęđ ővęř ŧĥę şämę ęväľūäŧįőʼn įʼnŧęřväľ.", + "evaluation-behaviour-description3": "Pęʼnđįʼnģ pęřįőđ şpęčįƒįęş ĥőŵ ľőʼnģ ŧĥę ŧĥřęşĥőľđ čőʼnđįŧįőʼn mūşŧ þę męŧ þęƒőřę ŧĥę äľęřŧ şŧäřŧş ƒįřįʼnģ. Ŧĥįş őpŧįőʼn ĥęľpş přęvęʼnŧ äľęřŧş ƒřőm þęįʼnģ ŧřįģģęřęđ þy ŧęmpőřäřy įşşūęş.", + "evaluation-behaviour-for": { + "error-parsing": "Fäįľęđ ŧő päřşę đūřäŧįőʼn", + "validation": "Pęʼnđįʼnģ pęřįőđ mūşŧ þę ģřęäŧęř ŧĥäʼn őř ęqūäľ ŧő ŧĥę ęväľūäŧįőʼn įʼnŧęřväľ." + }, + "folder": { + "cancel": "Cäʼnčęľ", + "create": "Cřęäŧę", + "create-folder": "Cřęäŧę ä ʼnęŵ ƒőľđęř ŧő şŧőřę yőūř äľęřŧ řūľę įʼn.", + "creating-new-folder": "Cřęäŧįʼnģ ʼnęŵ ƒőľđęř", + "label": "Főľđęř", + "name": "Főľđęř ʼnämę", + "new-folder": "Ńęŵ ƒőľđęř", + "new-folder-or": "őř" + }, + "folder-and-labels": "Øřģäʼnįžę yőūř äľęřŧ řūľę ŵįŧĥ ä ƒőľđęř äʼnđ şęŧ őƒ ľäþęľş.", + "folders": { + "help-info": "Főľđęřş äřę ūşęđ ƒőř şŧőřįʼnģ äľęřŧ řūľęş. Ÿőū čäʼn ęχŧęʼnđ ŧĥę äččęşş přővįđęđ þy ä řőľę ŧő äľęřŧ řūľęş äʼnđ äşşįģʼn pęřmįşşįőʼnş ŧő įʼnđįvįđūäľ ƒőľđęřş." + }, + "labels": { + "help-info": "Ŀäþęľş äřę ūşęđ ŧő đįƒƒęřęʼnŧįäŧę äʼn äľęřŧ ƒřőm äľľ őŧĥęř äľęřŧş.Ÿőū čäʼn ūşę ŧĥęm ƒőř şęäřčĥįʼnģ, şįľęʼnčįʼnģ, äʼnđ řőūŧįʼnģ ʼnőŧįƒįčäŧįőʼnş." + }, + "pause": { + "label": "Päūşę ęväľūäŧįőʼn" + } + }, "rule-groups": { "delete": { "success": "Ŝūččęşşƒūľľy đęľęŧęđ řūľę ģřőūp" From 39707fcc8cd0a1737e0c299cdd20f72aa17f6d16 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:40:16 +0000 Subject: [PATCH 32/60] Update dependency @grafana/faro-web-tracing to v1.12.1 (#96698) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 39926c3f887..41263291898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3667,7 +3667,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.12.1, @grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.12.1, @grafana/faro-web-sdk@npm:^1.12.1, @grafana/faro-web-sdk@npm:^1.3.6": version: 1.12.1 resolution: "@grafana/faro-web-sdk@npm:1.12.1" dependencies: @@ -3679,10 +3679,10 @@ __metadata: linkType: hard "@grafana/faro-web-tracing@npm:^1.8.2": - version: 1.12.0 - resolution: "@grafana/faro-web-tracing@npm:1.12.0" + version: 1.12.1 + resolution: "@grafana/faro-web-tracing@npm:1.12.1" dependencies: - "@grafana/faro-web-sdk": "npm:^1.12.0" + "@grafana/faro-web-sdk": "npm:^1.12.1" "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/context-zone": "npm:1.26.0" "@opentelemetry/core": "npm:^1.26.0" @@ -3694,7 +3694,7 @@ __metadata: "@opentelemetry/resources": "npm:^1.26.0" "@opentelemetry/sdk-trace-web": "npm:^1.26.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" - checksum: 10/287df82d928846ec35b5947a776fa066c76ccd1bceee57b68276825f3f2dffd05e003460668340bde3a0b163f420db74285bebff32cf33f954accf3a594c1702 + checksum: 10/3635d15bf22bf210d9758dfc0ac0562101ff50cccb18d006bbff282e219486e02847a6d2475e2bf76ca509a4862c533fce84c528134dfceef92a4d7985e0e746 languageName: node linkType: hard From a288ec6450743f3732e5823c1bc02ad37160acb4 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:49:32 +0000 Subject: [PATCH 33/60] Release: update changelog for 11.1.9 (#96692) Update changelog Co-authored-by: github-actions[bot] Co-authored-by: Andreas Christou --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4683bc9985..caf40d9bfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ + + +# 11.1.9 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96132](https://github.com/grafana/grafana/pull/96132), [@fayzal-g](https://github.com/fayzal-g) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95488](https://github.com/grafana/grafana/pull/95488), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Force refetch prom rules when refreshing panel [#96123](https://github.com/grafana/grafana/pull/96123), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94992](https://github.com/grafana/grafana/pull/94992), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95153](https://github.com/grafana/grafana/pull/95153), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95245](https://github.com/grafana/grafana/pull/95245), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95907](https://github.com/grafana/grafana/pull/95907), [@aangelisc](https://github.com/aangelisc) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95230](https://github.com/grafana/grafana/pull/95230), [@kalleep](https://github.com/kalleep) +- **Prometheus:** Fix interpolating adhoc filters with template variables [#95977](https://github.com/grafana/grafana/pull/95977), [@cazeaux](https://github.com/cazeaux) +- **ServerLock:** Fix pg concurrency/locking issue [#95933](https://github.com/grafana/grafana/pull/95933), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94969](https://github.com/grafana/grafana/pull/94969), [@scottlepp](https://github.com/scottlepp) + + # 11.0.8 (2024-11-19) From f427f8dc15c3c633e0c8b4ed12d05320a3fc4d80 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:59:59 +0100 Subject: [PATCH 34/60] Release: update changelog for 11.2.4 (#96691) Update changelog Co-authored-by: github-actions[bot] Co-authored-by: Andreas Christou --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf40d9bfeb..559605c2753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ + + +# 11.2.4 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96133](https://github.com/grafana/grafana/pull/96133), [@fayzal-g](https://github.com/fayzal-g) +- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96344](https://github.com/grafana/grafana/pull/96344), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Transformations:** Add 'transpose' transform [#95076](https://github.com/grafana/grafana/pull/95076), [@jmdane](https://github.com/jmdane) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95489](https://github.com/grafana/grafana/pull/95489), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Force refetch prom rules when refreshing panel [#96124](https://github.com/grafana/grafana/pull/96124), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94993](https://github.com/grafana/grafana/pull/94993), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95154](https://github.com/grafana/grafana/pull/95154), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95246](https://github.com/grafana/grafana/pull/95246), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95908](https://github.com/grafana/grafana/pull/95908), [@aangelisc](https://github.com/aangelisc) +- **Folders:** Add admin permissions upon creation of a folder w. SA [#95416](https://github.com/grafana/grafana/pull/95416), [@eleijonmarck](https://github.com/eleijonmarck) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95231](https://github.com/grafana/grafana/pull/95231), [@kalleep](https://github.com/kalleep) +- **ServerLock:** Fix pg concurrency/locking issue [#95934](https://github.com/grafana/grafana/pull/95934), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94959](https://github.com/grafana/grafana/pull/94959), [@samjewell](https://github.com/samjewell) + + # 11.1.9 (2024-11-19) From 3af87261a8f6387eab7cb28ffecbde0b9f7fc5f7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:02:59 +0000 Subject: [PATCH 35/60] Update dependency eslint-plugin-unicorn to v56.0.1 (#96709) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 41263291898..a2a7519d7c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16951,8 +16951,8 @@ __metadata: linkType: hard "eslint-plugin-unicorn@npm:^56.0.0": - version: 56.0.0 - resolution: "eslint-plugin-unicorn@npm:56.0.0" + version: 56.0.1 + resolution: "eslint-plugin-unicorn@npm:56.0.1" dependencies: "@babel/helper-validator-identifier": "npm:^7.24.7" "@eslint-community/eslint-utils": "npm:^4.4.0" @@ -16972,7 +16972,7 @@ __metadata: strip-indent: "npm:^3.0.0" peerDependencies: eslint: ">=8.56.0" - checksum: 10/142c66c65b2fd53136727a434b0fc77e9a9f9614aebe09330aeab83b021c842c3a5f9dafe3130c0f39fbd3562e91aadcc55a9de4312639e70fe7efb475cd358e + checksum: 10/59e13ded0e6bf4eff96018f3156829044d6edc37f66d25bc8ca99ed2b44e0cd3e9959587cf5a111498ff2d267a92bed49ade4f4dad98dcd8544e9edd9f6642b2 languageName: node linkType: hard From aace94438e211f7502bf9fedb27169a9ca236b65 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 19 Nov 2024 17:12:41 +0000 Subject: [PATCH 36/60] Logs: Allow scroll to reach the bottom of the log list before loading more (#96668) * Infinite scroll: use timestamps to improve scrolling experience * Add unit tests --- .../logs/components/InfiniteScroll.test.tsx | 50 ++++++++++++++- .../logs/components/InfiniteScroll.tsx | 63 +++++++++++++++++-- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index e5e7854dd72..666932be9d9 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -74,11 +74,14 @@ function setup( wheel(-1); } } - function wheel(deltaY: number) { + function wheel(deltaY: number, timeStamp?: number) { element.scrollTop += deltaY; act(() => { const event = new WheelEvent('wheel', { deltaY }); + if (timeStamp) { + jest.spyOn(event, 'timeStamp', 'get').mockReturnValue(timeStamp); + } events['wheel'](event); }); } @@ -164,7 +167,7 @@ describe('InfiniteScroll', () => { test.each([ ['up', -5, 0], - ['down', 5, 100], + ['down', 5, 60], ])( 'Requests more logs when moving the mousewheel %s', async (_: string, deltaY: number, startPosition: number) => { @@ -273,6 +276,49 @@ describe('InfiniteScroll', () => { } ); }); + + describe('Chain of events', () => { + test('Ingnores chains of events', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 4]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).not.toHaveBeenCalled(); + }); + + test('Detects when chain of events ends', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 600, 1]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + + test('Detects when the user wants to scroll', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + for (let i = 0; i <= 25; i++) { + wheel(1, 399 * i + 399); + } + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + }); } ); diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index 66ff34e4452..d8cbeb918f3 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange } from '@grafana/data'; import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil'; @@ -41,6 +41,8 @@ export const InfiniteScroll = ({ const [lowerLoading, setLowerLoading] = useState(false); const rowsRef = useRef(rows); const lastScroll = useRef(scrollElement?.scrollTop || 0); + const lastEvent = useRef(null); + const countRef = useRef(0); // Reset messages when range/order/rows change useEffect(() => { @@ -84,12 +86,14 @@ export const InfiniteScroll = ({ if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) { return; } - event.stopImmediatePropagation(); - const scrollDirection = shouldLoadMore(event, scrollElement, lastScroll.current); + const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); + lastEvent.current = event; lastScroll.current = scrollElement.scrollTop; if (scrollDirection === ScrollDirection.NoScroll) { return; - } else if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { + } + event.stopImmediatePropagation(); + if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { scrollTop(); } else if (scrollDirection === ScrollDirection.Bottom) { scrollBottom(); @@ -215,7 +219,13 @@ enum ScrollDirection { Bottom = 1, NoScroll = 0, } -function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, lastScroll: number): ScrollDirection { +function shouldLoadMore( + event: Event | WheelEvent, + lastEvent: Event | WheelEvent | null, + countRef: MutableRefObject, + element: HTMLDivElement, + lastScroll: number +): ScrollDirection { // Disable behavior if there is no scroll if (element.scrollHeight <= element.clientHeight) { return ScrollDirection.NoScroll; @@ -224,13 +234,54 @@ function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, last if (delta === 0) { return ScrollDirection.NoScroll; } + const scrollDirection = delta < 0 ? ScrollDirection.Top : ScrollDirection.Bottom; const diff = scrollDirection === ScrollDirection.Top ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight; - return diff <= 1 ? scrollDirection : ScrollDirection.NoScroll; + if (diff > 1) { + return ScrollDirection.NoScroll; + } + + if (lastEvent && shouldIgnoreChainOfEvents(event, lastEvent, countRef)) { + return ScrollDirection.NoScroll; + } + + return scrollDirection; +} + +function shouldIgnoreChainOfEvents( + event: Event | WheelEvent, + lastEvent: Event | WheelEvent, + countRef: MutableRefObject +) { + const deltaTime = event.timeStamp - lastEvent.timeStamp; + // Not a chain of events + if (deltaTime > 500) { + countRef.current = 0; + return false; + } + countRef.current++; + // Likely trackpad + if (deltaTime < 100) { + // User likely to want more results + if (countRef.current >= 180) { + countRef.current = 0; + return false; + } + return true; + } + // Likely mouse wheel + if (deltaTime < 400) { + // User likely to want more results + if (countRef.current >= 25) { + countRef.current = 0; + return false; + } + } + return true; } function getVisibleRange(rows: LogRowModel[]) { From d8c19136bf4776fca823e2f5605a8da6e51680ba Mon Sep 17 00:00:00 2001 From: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:17:01 -0700 Subject: [PATCH 37/60] CI: Fix `latest` tag not being correctly applied to releases (#96497) * baldm0mma/tag_latest_bug/ update github-release.yml default values * baldm0mma/tag_latest_bug/ update release-comms.yml * baldm0mma/tag_latest_bug/ update logic * baldm0mma/tag_latest_bug/ update logic * baldm0mma/tag_latest_bug/ update logic * baldm0mma/tag_latest_bug/ update logic for both cases * baldm0mma/tag_latest_bug/ update comment --- .github/workflows/github-release.yml | 3 ++- .github/workflows/release-comms.yml | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index a46a12134bf..8cbe3030ffe 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -8,7 +8,7 @@ on: type: string latest: required: false - default: false + default: "0" description: Mark this release as latest (`1`) or not (`0`, default) type: string dry_run: @@ -23,6 +23,7 @@ on: type: string latest: required: false + default: "0" description: Mark this release as latest (`1`) or not (`0`, default) type: string dry_run: diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index dc7355ef0b8..02d53057184 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -8,10 +8,11 @@ on: dry_run: required: false default: true + type: boolean version: required: true latest: - type: bool + type: boolean default: false pull_request: types: @@ -30,17 +31,18 @@ jobs: latest: ${{ steps.output.outputs.latest }} runs-on: ubuntu-latest steps: + # The github-release action expects a `LATEST` value of a string of either '1' or '0' - if: ${{ github.event_name == 'workflow_dispatch' }} run: | echo setting up GITHUB_ENV for ${{ github.event_name }} echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV - echo "LATEST=${{ inputs.latest }}" >> $GITHUB_ENV + echo "LATEST=${{ inputs.latest && '1' || '0' }}" >> $GITHUB_ENV - if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') }} run: | echo "VERSION=$(echo ${{ github.head_ref }} | sed -e 's/release\/.*\///g')" >> $GITHUB_ENV echo "DRY_RUN=${{ contains(github.event.pull_request.labels.*.name, 'release/dry-run') }}" >> $GITHUB_ENV - echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') }}" >> $GITHUB_ENV + echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') && '1' || '0' }}" >> $GITHUB_ENV - id: output run: | echo "dry_run: $DRY_RUN" From af9dd65c01d01787964a461e723b9e82e0314f9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:22:55 +0000 Subject: [PATCH 38/60] Update dependency i18next to v23.16.6 (#96715) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a2a7519d7c4..7d456a5344c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19739,11 +19739,11 @@ __metadata: linkType: hard "i18next@npm:^23.0.0, i18next@npm:^23.11.5, i18next@npm:^23.5.1": - version: 23.16.5 - resolution: "i18next@npm:23.16.5" + version: 23.16.6 + resolution: "i18next@npm:23.16.6" dependencies: "@babel/runtime": "npm:^7.23.2" - checksum: 10/842b6d35d6e0822f1c8c3c2ca8af0fec9d8d9e37a54f63cdf42c0fcc7908d980082daff6ab77ad248324d2c45a0f1fe1e40efe23ba84d950722ece36b61d647c + checksum: 10/38e8e92707c5c9eec0ba9fc46b0b036889467e738462b101478665e9aedd54570cc33bc3e6c891c2ec15ab683673a5ecacc0ae61ed5c91f2d1cd872d4c36cfc2 languageName: node linkType: hard From 82a2d9b9e2f93496a5d0468ef6a60b13449ae62b Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:33:14 +0000 Subject: [PATCH 39/60] Release: update changelog for 11.3.1 (#96713) Update changelog Co-authored-by: github-actions[bot] Co-authored-by: Andreas Christou --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 559605c2753..4469fb9a34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ + + +# 11.3.1 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96135](https://github.com/grafana/grafana/pull/96135), [@fayzal-g](https://github.com/fayzal-g) +- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96345](https://github.com/grafana/grafana/pull/96345), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95490](https://github.com/grafana/grafana/pull/95490), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Fix escaping of silence matchers in utf8 mode [#95347](https://github.com/grafana/grafana/pull/95347), [@tomratcliffe](https://github.com/tomratcliffe) +- **Alerting:** Fix overflow for long receiver names [#95133](https://github.com/grafana/grafana/pull/95133), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix saving advanced mode toggle state in the alert rule editor [#95981](https://github.com/grafana/grafana/pull/95981), [@alexander-akhmetov](https://github.com/alexander-akhmetov) +- **Alerting:** Fix setting datasource uid, when datasource is string in old version [#96273](https://github.com/grafana/grafana/pull/96273), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Force refetch prom rules when refreshing panel [#96125](https://github.com/grafana/grafana/pull/96125), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94994](https://github.com/grafana/grafana/pull/94994), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95155](https://github.com/grafana/grafana/pull/95155), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95247](https://github.com/grafana/grafana/pull/95247), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95909](https://github.com/grafana/grafana/pull/95909), [@aangelisc](https://github.com/aangelisc) +- **CloudWatch:** Interpolate region in log context query [#94990](https://github.com/grafana/grafana/pull/94990), [@iwysiu](https://github.com/iwysiu) +- **Dashboard datasource:** Return annotations as series when query topic is "annotations" [#95971](https://github.com/grafana/grafana/pull/95971), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard:** Append orgId to URL [#95963](https://github.com/grafana/grafana/pull/95963), [@bfmatei](https://github.com/bfmatei) +- **Dashboards:** Fixes performance issue expanding a row [#95321](https://github.com/grafana/grafana/pull/95321), [@torkelo](https://github.com/torkelo) +- **Flame Graph:** Fix crash when it receives empty data [#96211](https://github.com/grafana/grafana/pull/96211), [@yincongcyincong](https://github.com/yincongcyincong) +- **Folders:** Add admin permissions upon creation of a folder w. SA [#95365](https://github.com/grafana/grafana/pull/95365), [@eleijonmarck](https://github.com/eleijonmarck) +- **Folders:** Don't show error pop-up if the user can't fetch the root folder [#95600](https://github.com/grafana/grafana/pull/95600), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95232](https://github.com/grafana/grafana/pull/95232), [@kalleep](https://github.com/kalleep) +- **ServerLock:** Fix pg concurrency/locking issue [#95935](https://github.com/grafana/grafana/pull/95935), [@mgyongyosi](https://github.com/mgyongyosi) +- **Service Accounts:** Run service account creation in transaction [#94803](https://github.com/grafana/grafana/pull/94803), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Table:** Fix text wrapping applying to wrong field [#95425](https://github.com/grafana/grafana/pull/95425), [@codeincarnate](https://github.com/codeincarnate) +- **Unified Storage:** Use ssl_mode instead of sslmode [#95662](https://github.com/grafana/grafana/pull/95662), [@chaudyg](https://github.com/chaudyg) + + # 11.2.4 (2024-11-19) From 2ffb7344e8fdc00311ad3e603e41047d7c763f8e Mon Sep 17 00:00:00 2001 From: Ieva Date: Tue, 19 Nov 2024 18:27:34 +0000 Subject: [PATCH 40/60] GAS: update auth provider docs (#96097) * add docs describing what GAS is and how it can be configured * auth provider docs for GAS * PR feedback --- .../introduction/grafana-enterprise.md | 14 +++++++------ .../configure-authentication/azuread/index.md | 8 ++++---- .../enhanced-ldap/index.md | 14 ++++++------- .../generic-oauth/index.md | 16 ++++++++------- .../configure-authentication/github/index.md | 12 +++++------ .../configure-authentication/gitlab/index.md | 16 +++++++-------- .../configure-authentication/google/index.md | 15 +++++++++----- .../keycloak/index.md | 20 +++++++++++-------- .../configure-authentication/okta/index.md | 18 ++++++++--------- .../configure-authentication/saml-ui/index.md | 4 ++-- .../configure-authentication/saml/index.md | 13 +++++++----- .../configure-group-attribute-sync.md | 11 ++++++++++ .../configure-security/configure-team-sync.md | 17 ++++++++-------- 13 files changed, 101 insertions(+), 77 deletions(-) diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 0a9a17ca79b..ccd311d14fe 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -34,12 +34,14 @@ Grafana Enterprise includes integrations with more ways to authenticate your use Supported auth providers: - [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) -- [Azure AD OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/azuread#team-sync-enterprise-only" >}}) -- [GitHub OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/github#configure-team-synchronization" >}}) -- [GitLab OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/gitlab#configure-team-synchronization" >}}) -- [LDAP]({{< relref "../setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}}) -- [Okta]({{< relref "../setup-grafana/configure-security/configure-authentication/okta#configure-team-synchronization-enterprise-only" >}}) -- [SAML]({{< relref "../setup-grafana/configure-security/configure-authentication/saml#configure-team-sync" >}}) +- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization" >}}) +- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization) ### Enhanced LDAP integration diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index dfbea92ba5f..4c4d8d6e81e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -353,14 +353,14 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Team Sync (Enterprise only) +### Group sync (Enterprise only) -With Team Sync you can map your Entra ID groups to teams in Grafana so that your users will automatically be added to -the correct teams. +With group sync you can map your Entra ID groups to teams and roles in Grafana. This allows users to automatically be added to +the correct teams and be granted the correct roles in Grafana. You can reference Entra ID groups by group object ID, like `8bab1c86-8fba-33e5-2089-1d1c80ec267d`. -To learn more, refer to the [Team Sync]({{< relref "../../configure-team-sync" >}}) documentation. +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Common troubleshooting diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md index edc4057ec5f..a8992e015d6 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md @@ -28,18 +28,16 @@ The enhanced LDAP integration adds additional functionality on top of the [LDAP > To control user access with role-based permissions, refer to [role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}). -## LDAP group synchronization for teams +## LDAP group synchronization -With enhanced LDAP integration, you can set up synchronization between LDAP groups and teams. This enables LDAP users that are members -of certain LDAP groups to automatically be added or removed as members to certain teams in Grafana. +With enhanced LDAP integration, you can set up synchronization between LDAP groups and Grafana teams and roles. This enables users that are members +of certain LDAP groups to automatically be added to teams and gain roles in Grafana. + +The below example shows an LDAP group member mapped to a Grafana team. ![LDAP group synchronization](/static/img/docs/enterprise/team_members_ldap.png) -Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot. -This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also allows you to manually add -a user as member of a team, and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships. - -[Learn more about team sync.]({{< relref "../../configure-team-sync" >}}) +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync).
diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 13fce031d97..2d6eb5fd39d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -127,7 +127,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic c. Enable the refresh token on the provider if required. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a Generic OAuth login button on the login page and be able to log in or sign up with your OAuth2 provider. @@ -328,19 +328,21 @@ org_attribute_path = info.roles org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor ``` -### Configure team synchronization +## Configure group synchronization -> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can link your OAuth2 groups to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of OAuth2 groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or automatically granting them the mapped roles. +Teams and roles get synchronized when the user logs in. Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`. For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options]({{< relref "#configuration-options" >}}). -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). -#### Team synchronization example +#### Group attribute synchronization example Configuration: 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 600cc9f26e4..5792c3a4a70 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 @@ -110,7 +110,7 @@ To configure GitHub authentication with Grafana, follow these steps: Review the list of other GitHub [configuration options]({{< relref "#configuration-options" >}}) and complete them, as necessary. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a GitHub login button on the login page and be able to log in or sign up with your GitHub accounts. @@ -216,14 +216,14 @@ allowed_domains = mycompany.com mycompany.org role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ``` -## Configure team synchronization +## Configure group synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and Grafana Cloud. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). {{< /admonition >}} -By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of teams from your GitHub organization with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. GitHub teams can be referenced in two ways: @@ -232,7 +232,7 @@ GitHub teams can be referenced in two ways: Examples: `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`. -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Configuration options 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 075047b7c49..d1503808910 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 @@ -119,7 +119,7 @@ To configure GitLab authentication with Grafana, follow these steps: a. Set `use_refresh_token` to `true` in `[auth.gitlab]` section in Grafana configuration file. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a GitLab login button on the login page and be able to log in or sign up with your GitLab accounts. @@ -242,20 +242,20 @@ use_pkce = true use_refresh_token = true ``` -## Configure team synchronization +## Configure group synchronization -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of GitLab groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`. Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Configuration options diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index fed9af7ebd9..9e4740c6914 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -165,9 +165,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Configure team sync for Google OAuth +### Configure group synchronization -With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example. +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} + +Grafana supports syncing users to teams and roles based on their Google groups. + +To set up group sync for Google OAuth: 1. Enable the Google Cloud Identity API on your [organization's dashboard](https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com/). @@ -181,10 +187,9 @@ With team sync, you can easily add users to teams by utilizing their Google grou scopes = openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly ``` -1. Configure team sync in your Grafana team's `External group sync` tab. - The external group ID for a Google group is the group's email address, such as `dev@grafana.com`. +The external group ID for a Google group is the group's email address, such as `dev@grafana.com`. -To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. #### Configure allowed groups diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index 0759f30428c..323e6e1614d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -94,7 +94,7 @@ roles ``` {{% admonition type="warning" %}} -these scopes do not add group claims to the id_token. Without group claims, teamsync will not work. Teamsync is covered further down in this document. +These scopes do not add group claims to the id_token. Without group claims, group synchronization will not work. Group synchronization is covered further down in this document. {{% /admonition %}} 3. For role mapping to work with the example configuration above, @@ -106,16 +106,18 @@ editor viewer ``` -## Teamsync +## Group synchronization -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -[Teamsync]({{< relref "../../configure-team-sync" >}}) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership. +By using group synchronization, you can link your Keycloak groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +This is useful if you want to give your users access to specific resources based on their group membership. +Teams and roles get synchronized when the user logs in. -To enable teamsync, you need to add a `groups` mapper to the client configuration in Keycloak. -This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams in Grafana. +To enable group synchronization, you need to add a `groups` mapper to the client configuration in Keycloak. +This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams and roles in Grafana. 1. In the client configuration, head to `Mappers` and create a mapper with the following settings: @@ -141,6 +143,8 @@ If you use nested groups containing special characters such as quotes or colons, groups_attribute_path = reverse("Global:department") ``` +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. + ## Enable Single Logout To enable Single Logout, you need to add the following option to the configuration of Grafana: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index acf3e070cdd..39d77ca3f7f 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -169,7 +169,7 @@ To integrate your Okta OIDC provider with Grafana using our Okta OIDC integratio 1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}). 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization-enterprise-only" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization-enterprise-only" >}}). 1. Restart Grafana. You should now see a Okta OIDC login button on the login page and be able to log in or sign up with your OIDC provider. @@ -243,20 +243,18 @@ org_attribute_path = groups org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"] ``` -### Configure team synchronization (Enterprise only) +### Configure group synchronization (Enterprise only) -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../../introduction/grafana-cloud" >}}). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams. - -Map your Okta groups to teams in Grafana so that your users will automatically be added to -the correct teams. +By using group synchronization, you can link your Okta groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. Okta groups can be referenced by group names, like `Admins` or `Editors`. -To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. ## Configuration options diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md index 187fd6356dc..1be5e2d993e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md @@ -113,8 +113,8 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure 1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section. - You also need to configure the **Groups attribute** field if you want to use team sync. Team sync automatically maps users to Grafana teams based on their SAML group membership. - Learn more about [team sync]({{< relref "../../configure-team-sync" >}}) and [configuring team sync for SAML]({{< relref "../saml#configure-team-sync" >}}). + You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership. + To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. 1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md index 08e01f5dcc2..230edd20cb4 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md @@ -410,12 +410,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Configure team sync +### Configure group synchronization -To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab. +Group synchronization allows you to map user groups from an identity provider to Grafana teams and roles. + +To use SAML group synchronization, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. +Then Grafana will use attribute values extracted from SAML assertion to add user to Grafana teams and grant them roles. {{% admonition type="note" %}} -Teamsync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature. +Team sync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature. {{% /admonition %}} Given the following partial SAML assertion: @@ -445,12 +448,12 @@ The configuration would look like this: assertion_attribute_groups = groups ``` -The following `External Group ID`s would be valid for input in the desired team's _External group sync_ tab: +The following `External Group ID`s would be valid for configuring team sync or role sync in Grafana: - `admins_group` - `division_1` -[Learn more about Team Sync]({{< relref "../../configure-team-sync" >}}) +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. ### Configure role sync diff --git a/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md b/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md index 1377f885397..0ebe9f3db6c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md @@ -26,6 +26,17 @@ If a group mapping is removed, the role assignment to users for the group mappin Role mappings are tied to organizations, so you can have different mappings for different organizations. +## Supported providers + +- [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization) +- [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization) + ## Create role mappings for a new group For information about creating group mappings via the API, refer to [create group mappings reference](https://grafana.com/docs/grafana//developers/http_api/group_attribute_sync#create-group-mappings). diff --git a/docs/sources/setup-grafana/configure-security/configure-team-sync.md b/docs/sources/setup-grafana/configure-security/configure-team-sync.md index ad3fde7ab92..f5d50cff311 100644 --- a/docs/sources/setup-grafana/configure-security/configure-team-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-team-sync.md @@ -28,14 +28,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea ## Supported providers - [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) -- [Azure AD]({{< relref "./configure-authentication/azuread#team-sync-enterprise-only" >}}) -- [Generic OAuth integration]({{< relref "./configure-authentication/generic-oauth#configure-team-synchronization" >}}) -- [GitHub OAuth]({{< relref "./configure-authentication/github#configure-team-synchronization" >}}) -- [GitLab OAuth]({{< relref "./configure-authentication/gitlab#configure-team-synchronization" >}}) -- [Google OAuth]({{< relref "./configure-authentication/google#configure-team-sync-for-google-oauth" >}}) -- [LDAP]({{< relref "./configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}}) -- [Okta]({{< relref "./configure-authentication/okta#configure-team-synchronization-enterprise-only" >}}) -- [SAML]({{< relref "./configure-authentication/saml#configure-team-sync" >}}) +- [Azure AD](https://grafana.com/docs/grafana//configure-authentication/azuread#group-sync-enterprise-only) +- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization) +- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization) ## Synchronize a Grafana team with an external group From 8527f986dbb9e8b7cfb40e6217ff85dc75cd9ec9 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue, 19 Nov 2024 18:55:14 +0000 Subject: [PATCH 41/60] PanelInspectDrawer: Fix issue where closing inspect drawer on home dashboard redirects to new dashboard (#96711) --- .../features/dashboard-scene/inspect/PanelInspectDrawer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 59b38232d74..285ac26b6a0 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -92,6 +92,8 @@ export class PanelInspectDrawer extends SceneObjectBase onClose = () => { const dashboard = getDashboardSceneFor(this); + const meta = dashboard.state.meta; + locationService.push( getDashboardUrl({ uid: dashboard.state.uid, @@ -101,6 +103,7 @@ export class PanelInspectDrawer extends SceneObjectBase inspect: null, inspectTab: null, }, + isHomeDashboard: !meta.url && !meta.slug && !meta.isNew, }) ); }; From 51efde972b74ef0d76edf86dbb8afc64edee4bda Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Tue, 19 Nov 2024 20:01:56 +0100 Subject: [PATCH 42/60] Loki: Allow regex in `label` derived field (#96609) * Loki: Allow regex in `label` derived field * add docs * change to caution --- .../loki/configure-loki-data-source.md | 8 +++++-- .../datasource/loki/getDerivedFields.test.ts | 24 +++++++++++++++++++ .../datasource/loki/getDerivedFields.ts | 9 +++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/sources/datasources/loki/configure-loki-data-source.md b/docs/sources/datasources/loki/configure-loki-data-source.md index b667c8e1bf8..8dc6eb1670c 100644 --- a/docs/sources/datasources/loki/configure-loki-data-source.md +++ b/docs/sources/datasources/loki/configure-loki-data-source.md @@ -114,9 +114,13 @@ Each derived field consists of the following: - **Type** - Defines the type of the derived field. It can be either: - - **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. +{{% admonition type="caution" %}} +Using complex regular expressions in either type can impact browser performance when processing large volumes of logs. Consider using simpler patterns when possible. +{{% /admonition %}} - - **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. The label's value will be used as the value of the derived field. +- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. + +- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. When using this type, the input will match as a regular expression against label keys, allowing you to match variations like `traceid` and `trace_id` with a single regex pattern like `trace[_]?id`. The value of the matched label will be used as the value of the derived field. - **URL/query** Sets the full link URL if the link is external, or a query for the target data source if the link is internal. You can interpolate the value from the field with the `${__value.raw}` macro. diff --git a/public/app/plugins/datasource/loki/getDerivedFields.test.ts b/public/app/plugins/datasource/loki/getDerivedFields.test.ts index 674d95cea91..7b56f5a5751 100644 --- a/public/app/plugins/datasource/loki/getDerivedFields.test.ts +++ b/public/app/plugins/datasource/loki/getDerivedFields.test.ts @@ -192,4 +192,28 @@ describe('getDerivedFields', () => { title: '', }); }); + + it('matches label keys using regex when matcherType is label', () => { + const df = createDataFrame({ + fields: [ + { name: 'labels', values: [{ traceId: 'abc' }, { traceID: 'xyz' }] }, + { name: 'line', values: ['log1', 'log2'] }, + ], + }); + const newFields = getDerivedFields(df, [ + { + matcherRegex: 'traceI(d|D)', + name: 'traceIdFromLabel', + url: 'http://localhost/${__value.raw}', + matcherType: 'label', + }, + ]); + expect(newFields.length).toBe(1); + const traceId = newFields.find((f) => f.name === 'traceIdFromLabel'); + expect(traceId!.values).toEqual(['abc', 'xyz']); + expect(traceId!.config.links![0]).toEqual({ + url: 'http://localhost/${__value.raw}', + title: '', + }); + }); }); diff --git a/public/app/plugins/datasource/loki/getDerivedFields.ts b/public/app/plugins/datasource/loki/getDerivedFields.ts index e28c697adfc..8a23e1d54ff 100644 --- a/public/app/plugins/datasource/loki/getDerivedFields.ts +++ b/public/app/plugins/datasource/loki/getDerivedFields.ts @@ -30,10 +30,11 @@ export function getDerivedFields(dataFrame: DataFrame, derivedFieldConfigs: Deri if (derivedFieldsGrouped[field.name][0].matcherType === 'label' && labelFields) { const label = labelFields.values[i]; if (label) { - // Find the key that matches both, the `matcherRegex` and the label key - const intersectingKey = Object.keys(label).find( - (key) => derivedFieldsGrouped[field.name][0].matcherRegex === key - ); + // Find the key that matches the regex pattern in `matcherRegex` + const intersectingKey = Object.keys(label).find((key) => { + const regex = new RegExp(derivedFieldsGrouped[field.name][0].matcherRegex); + return regex.test(key); + }); if (intersectingKey) { field.values.push(label[intersectingKey]); From 33bf94f4d2aa2d549540114bc45270c5e67c5b72 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 19 Nov 2024 14:04:31 -0600 Subject: [PATCH 43/60] Transformations: Add RegExp option to Extract fields transformer (#96593) --- .../transform-data/index.md | 1 + .../app/features/transformers/docs/content.ts | 1 + .../ExtractFieldsTransformerEditor.tsx | 22 ++++++- .../extractFields/extractFields.ts | 3 +- .../extractFields/fieldExtractor.test.ts | 32 +++++++--- .../extractFields/fieldExtractors.ts | 59 ++++++++++++++----- .../transformers/extractFields/types.ts | 2 + 7 files changed, 92 insertions(+), 28 deletions(-) diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index 553f08b452e..d0ac307a1bc 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -338,6 +338,7 @@ Use this transformation to select a source of data and extract content from it i - **Format** - Choose one of the following: - **JSON** - Parse JSON content from the source. - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. + - **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group). - **Auto** - Discover fields automatically. - **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. - **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index 884b5edfb0d..dc91b1152b8 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -240,6 +240,7 @@ Use this transformation to select a source of data and extract content from it i - **Format** - Choose one of the following: - **JSON** - Parse JSON content from the source. - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. + - **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group). - **Auto** - Discover fields automatically. - **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. - **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. diff --git a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx index 3261a77d7e2..e08b4065732 100644 --- a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx @@ -1,3 +1,5 @@ +import { ChangeEvent } from 'react'; + import { DataTransformerID, TransformerRegistryItem, @@ -7,7 +9,7 @@ import { StandardEditorsRegistryItem, TransformerCategory, } from '@grafana/data'; -import { InlineField, InlineFieldRow, Select, InlineSwitch } from '@grafana/ui'; +import { InlineField, InlineFieldRow, Select, InlineSwitch, Input } from '@grafana/ui'; import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; import { getTransformationContent } from '../docs/getTransformationContent'; @@ -53,6 +55,13 @@ export const extractFieldsTransformerEditor = ({ }); }; + const onRegexpChange = (e: ChangeEvent) => { + onChange({ + ...options, + regExp: e.target.value, + }); + }; + const onToggleReplace = () => { if (options.replace) { options.keepTime = false; @@ -96,7 +105,16 @@ export const extractFieldsTransformerEditor = ({ /> - {options.format === 'json' && } + {options.format === FieldExtractorID.RegExp && ( + + + + + + )} + {options.format === FieldExtractorID.JSON && ( + + )} diff --git a/public/app/features/transformers/extractFields/extractFields.ts b/public/app/features/transformers/extractFields/extractFields.ts index 2175adf4ac8..d686d262332 100644 --- a/public/app/features/transformers/extractFields/extractFields.ts +++ b/public/app/features/transformers/extractFields/extractFields.ts @@ -52,13 +52,14 @@ export function addExtractedFields(frame: DataFrame, options: ExtractFieldsOptio const count = frame.length; const names: string[] = []; // keep order const values = new Map(); + const parse = ext.getParser(options); for (let i = 0; i < count; i++) { let obj = source.values[i]; if (isString(obj)) { try { - obj = ext.parse(obj); + obj = parse(obj); } catch { obj = {}; // empty } diff --git a/public/app/features/transformers/extractFields/fieldExtractor.test.ts b/public/app/features/transformers/extractFields/fieldExtractor.test.ts index 6757acc3d90..820c3843e1a 100644 --- a/public/app/features/transformers/extractFields/fieldExtractor.test.ts +++ b/public/app/features/transformers/extractFields/fieldExtractor.test.ts @@ -3,12 +3,12 @@ import { config } from '@grafana/runtime'; import { addExtractedFields } from './extractFields'; import { fieldExtractors } from './fieldExtractors'; -import { FieldExtractorID } from './types'; +import { ExtractFieldsOptions, FieldExtractorID } from './types'; describe('Extract fields from text', () => { it('JSON extractor', async () => { const extractor = fieldExtractors.get(FieldExtractorID.JSON); - const out = extractor.parse('{"a":"148.1672","av":41923755,"c":148.25}'); + const out = extractor.getParser({})('{"a":"148.1672","av":41923755,"c":148.25}'); expect(out).toMatchInlineSnapshot(` { @@ -21,7 +21,7 @@ describe('Extract fields from text', () => { it('Test key-values with single/double quotes', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a="1", "b"=\'2\',c=3 x:y ;\r\nz="d and 4"'); + const out = extractor.getParser({})('a="1", "b"=\'2\',c=3 x:y ;\r\nz="d and 4"'); expect(out).toMatchInlineSnapshot(` { "a": "1", @@ -35,7 +35,7 @@ describe('Extract fields from text', () => { it('Test key-values with nested single/double quotes', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse( + const out = extractor.getParser({})( `a="1", "b"=\'2\',c=3 x:y ;\r\nz="dbl_quotes=\\"Double Quotes\\" sgl_quotes='Single Quotes'"` ); @@ -52,7 +52,7 @@ describe('Extract fields from text', () => { it('Test key-values with nested separator characters', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse(`a="1", "b"=\'2\',c=3 x:y ;\r\nz="This is; testing& validating, 1=:2"`); + const out = extractor.getParser({})(`a="1", "b"=\'2\',c=3 x:y ;\r\nz="This is; testing& validating, 1=:2"`); expect(out).toMatchInlineSnapshot(` { @@ -67,7 +67,7 @@ describe('Extract fields from text', () => { it('Test key-values where some values are null', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse(`a=, "b"=\'2\',c=3 x: `); + const out = extractor.getParser({})(`a=, "b"=\'2\',c=3 x: `); expect(out).toMatchInlineSnapshot(` { @@ -81,7 +81,7 @@ describe('Extract fields from text', () => { it('Split key+values', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a="1", "b"=\'2\',c=3 x:y ;\r\nz="7"'); + const out = extractor.getParser({})('a="1", "b"=\'2\',c=3 x:y ;\r\nz="7"'); expect(out).toMatchInlineSnapshot(` { "a": "1", @@ -95,7 +95,7 @@ describe('Extract fields from text', () => { it('Split URL style parameters', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a=b&c=d&x=123'); + const out = extractor.getParser({})('a=b&c=d&x=123'); expect(out).toMatchInlineSnapshot(` { "a": "b", @@ -107,7 +107,7 @@ describe('Extract fields from text', () => { it('Prometheus labels style (not really supported)', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('{foo="bar", baz="42"}'); + const out = extractor.getParser({})('{foo="bar", baz="42"}'); expect(out).toMatchInlineSnapshot(` { "baz": "42", @@ -137,4 +137,18 @@ describe('Extract fields from text', () => { expect(newFrame.fields.length).toBe(2); expect(newFrame.fields[1].name).toBe('bar'); }); + + it('splits by regexp', async () => { + const extractor = fieldExtractors.get(FieldExtractorID.RegExp); + const opts: ExtractFieldsOptions = { regExp: '/^(?\\w+)[^\\w]+(?\\w+)$/' }; + const parse = extractor.getParser(opts); + const out = parse('abc - re30z'); + + expect(out).toMatchInlineSnapshot(` + { + "FieldA": "abc", + "FieldB": "re30z", + } + `); + }); }); diff --git a/public/app/features/transformers/extractFields/fieldExtractors.ts b/public/app/features/transformers/extractFields/fieldExtractors.ts index ba8af2b78d6..348984cd9db 100644 --- a/public/app/features/transformers/extractFields/fieldExtractors.ts +++ b/public/app/features/transformers/extractFields/fieldExtractors.ts @@ -1,20 +1,43 @@ -import { Registry, RegistryItem } from '@grafana/data'; +import { Registry, RegistryItem, stringStartsAsRegEx, stringToJsRegex } from '@grafana/data'; -import { FieldExtractorID } from './types'; +import { ExtractFieldsOptions, FieldExtractorID } from './types'; + +type Parser = (v: string) => Record | undefined; export interface FieldExtractor extends RegistryItem { - parse: (v: string) => Record | undefined; + getParser: (opts: ExtractFieldsOptions) => Parser; } const extJSON: FieldExtractor = { id: FieldExtractorID.JSON, name: 'JSON', description: 'Parse JSON string', - parse: (v: string) => { + getParser: (options) => (v: string) => { return JSON.parse(v); }, }; +const extRegExp: FieldExtractor = { + id: FieldExtractorID.RegExp, + name: 'RegExp', + description: 'Parse with RegExp', + getParser: (options) => { + let regex: RegExp | null = /(?.*)/; + + if (stringStartsAsRegEx(options.regExp!)) { + try { + regex = stringToJsRegex(options.regExp!); + } catch (error) { + if (error instanceof Error) { + console.warn(error.message); + } + } + } + + return (v: string) => v.match(regex)?.groups; + }, +}; + function parseKeyValuePairs(raw: string): Record { const buff: string[] = []; // array of characters let esc = ''; @@ -107,25 +130,29 @@ const extLabels: FieldExtractor = { id: FieldExtractorID.KeyValues, name: 'Key+value pairs', description: 'Look for a=b, c: d values in the line', - parse: parseKeyValuePairs, + getParser: (options) => parseKeyValuePairs, }; -const fmts = [extJSON, extLabels]; +const fmts = [extJSON, extLabels, extRegExp]; const extAuto: FieldExtractor = { id: FieldExtractorID.Auto, name: 'Auto', description: 'parse new fields automatically', - parse: (v: string) => { - for (const f of fmts) { - try { - const r = f.parse(v); - if (r != null) { - return r; - } - } catch {} // ignore errors - } - return undefined; + getParser: (options) => { + const parsers = fmts.map((fmt) => fmt.getParser(options)); + + return (v: string) => { + for (const parse of parsers) { + try { + const r = parse(v); + if (r != null) { + return r; + } + } catch {} // ignore errors + } + return undefined; + }; }, }; diff --git a/public/app/features/transformers/extractFields/types.ts b/public/app/features/transformers/extractFields/types.ts index e0f63aab4d1..b0204f14377 100644 --- a/public/app/features/transformers/extractFields/types.ts +++ b/public/app/features/transformers/extractFields/types.ts @@ -2,6 +2,7 @@ export enum FieldExtractorID { JSON = 'json', KeyValues = 'kvp', Auto = 'auto', + RegExp = 'regexp', } export interface JSONPath { @@ -11,6 +12,7 @@ export interface JSONPath { export interface ExtractFieldsOptions { source?: string; jsonPaths?: JSONPath[]; + regExp?: string; format?: FieldExtractorID; replace?: boolean; keepTime?: boolean; From b69af9240de35b1360e8ef3bfb60acbc3c2125df Mon Sep 17 00:00:00 2001 From: margotphelps <123196595+margotphelps@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:28:02 -0500 Subject: [PATCH 44/60] Added docket tag note to security section (#96725) Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- docs/sources/upgrade-guide/when-to-upgrade/index.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/sources/upgrade-guide/when-to-upgrade/index.md b/docs/sources/upgrade-guide/when-to-upgrade/index.md index 2c56ec67047..e3ee3e3bd12 100644 --- a/docs/sources/upgrade-guide/when-to-upgrade/index.md +++ b/docs/sources/upgrade-guide/when-to-upgrade/index.md @@ -82,7 +82,11 @@ During these times, no scheduled releases will be executed. However, this does n We've enhanced our naming convention for security release versions to make it easier to clearly identify our security releases from our standard patching releases. -In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update. +In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update.
+ +{{< admonition type="note" >}} +Docker does not allow the plus sign (`+`) in image tag names. A plus sign (`+`) will be a rendered as a dash (`-`) in the docker tag. +{{< /admonition >}} Our new approach directly addresses this issue. Going forward, security releases will be appended with "+security" to indicate that the release is the indicated version PLUS the security fix. From 2b2c94050c6bbb594a62a57a298a907b09d34975 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Tue, 19 Nov 2024 22:13:55 +0100 Subject: [PATCH 45/60] Docker: Don't use legacy ENV syntax (#93218) chore(docker): don't use legacy ENV syntax --- Dockerfile | 2 +- devenv/docker/blocks/collectd/Dockerfile | 2 +- devenv/docker/buildcontainer/Dockerfile | 8 ++++---- devenv/docker/ha-test-unified-alerting/Dockerfile | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2e4ec742bf4..9c71569fc58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY scripts scripts COPY emails emails -ENV NODE_ENV production +ENV NODE_ENV=production RUN yarn build FROM ${GO_IMAGE} as go-builder diff --git a/devenv/docker/blocks/collectd/Dockerfile b/devenv/docker/blocks/collectd/Dockerfile index ea046659bd1..00762cd0149 100644 --- a/devenv/docker/blocks/collectd/Dockerfile +++ b/devenv/docker/blocks/collectd/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:xenial -ENV DEBIAN_FRONTEND noninteractive +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get -y update RUN apt-get -y install collectd curl python-pip diff --git a/devenv/docker/buildcontainer/Dockerfile b/devenv/docker/buildcontainer/Dockerfile index ae85c8fbad2..a6a636439c3 100644 --- a/devenv/docker/buildcontainer/Dockerfile +++ b/devenv/docker/buildcontainer/Dockerfile @@ -2,18 +2,18 @@ FROM centos:6.6 RUN yum install -y initscripts curl tar gcc libc6-dev git -ENV GOLANG_VERSION 1.4.2 +ENV GOLANG_VERSION=1.4.2 RUN curl -sSL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz \ | tar -v -C /usr/src -xz RUN cd /usr/src/go/src && ./make.bash --no-clean 2>&1 -ENV PATH /usr/src/go/bin:$PATH +ENV PATH=/usr/src/go/bin:$PATH RUN mkdir -p /go/src /go/bin && chmod -R 777 /go -ENV GOPATH /go -ENV PATH /go/bin:$PATH +ENV GOPATH=/go +ENV PATH=/go/bin:$PATH WORKDIR /go/src/github.com/grafana/grafana diff --git a/devenv/docker/ha-test-unified-alerting/Dockerfile b/devenv/docker/ha-test-unified-alerting/Dockerfile index cc9b14e2e4e..38965c12636 100644 --- a/devenv/docker/ha-test-unified-alerting/Dockerfile +++ b/devenv/docker/ha-test-unified-alerting/Dockerfile @@ -7,6 +7,6 @@ WORKDIR /go/src/webhook RUN mkdir /tmp/logs RUN go build -o /bin webhook-listener.go -ENV PORT 8080 +ENV PORT=8080 ENTRYPOINT [ "/bin/webhook-listener" ] From 3e2d849850c5d3ba701af51bd47d849830646967 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Tue, 19 Nov 2024 16:42:42 -0600 Subject: [PATCH 46/60] Prometheus: Add deprecation message for AMP in Promehteus docs (#96689) deprecate message for AMP in prom docs --- docs/sources/datasources/prometheus/_index.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/sources/datasources/prometheus/_index.md b/docs/sources/datasources/prometheus/_index.md index 1482e497ed2..cd5cbb04ac1 100644 --- a/docs/sources/datasources/prometheus/_index.md +++ b/docs/sources/datasources/prometheus/_index.md @@ -163,17 +163,7 @@ For details about these metrics, refer to [Internal Grafana metrics](ref:set-up- ## Amazon Managed Service for Prometheus -The Prometheus data source works with Amazon Managed Service for Prometheus. - -If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain. - -For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). - -### AWS Signature Version 4 authentication - -To connect the Prometheus data source to Amazon Managed Service for Prometheus using SigV4 authentication, refer to the AWS guide to [Set up Grafana open source or Grafana Enterprise for use with AMP](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-standalone-grafana.html). - -If you run Grafana in an Amazon EKS cluster, follow the AWS guide to [Query using Grafana running in an Amazon EKS cluster](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-grafana-7.3.html). +The Prometheus data source with Amazon Managed Service for Prometheus is deprecated. Please use the [Amazon Managed service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/). Migrations steps are detailed in the link. ## Azure authentication settings From be59a6113b9c789725e5f69fed987f1447ade66c Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Tue, 19 Nov 2024 21:13:57 -0600 Subject: [PATCH 47/60] Explore metrics: Default for OTel to select deployment environment like prod (#96585) * default to select deployment environment like prod * add tests * return null if options are empty to prevent error, fix test --- public/app/features/trails/DataTrail.tsx | 11 ++++- public/app/features/trails/otel/util.ts | 15 ++++++ public/app/features/trails/otel/utils.test.ts | 48 ++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index dcae58bb515..69412411454 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -49,7 +49,13 @@ import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; import { reportChangeInLabelFilters } from './interactions'; import { getDeploymentEnvironments, TARGET_INFO_FILTER, totalOtelResources } from './otel/api'; import { OtelResourcesObject, OtelTargetType } from './otel/types'; -import { sortResources, getOtelJoinQuery, getOtelResourcesObject, updateOtelJoinWithGroupLeft } from './otel/util'; +import { + sortResources, + getOtelJoinQuery, + getOtelResourcesObject, + updateOtelJoinWithGroupLeft, + getProdOrDefaultOption, +} from './otel/util'; import { getOtelExperienceToggleState } from './services/store'; import { getVariablesWithOtelJoinQueryConstant, @@ -357,7 +363,8 @@ export class DataTrail extends SceneObjectBase { // We have to have a default value because custom variable requires it // we choose one default value to help filter metrics // The work flow for OTel begins with users selecting a deployment environment - const defaultDepEnv = options[0].value; // usually production + // default to production + let defaultDepEnv = getProdOrDefaultOption(options) ?? ''; // On starting the explore metrics workflow, the custom variable has no value // Even if there is state, the value is always '' // The only reference to state values are in the text diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index dc76fbeb623..ef935bc9017 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -284,3 +284,18 @@ export async function updateOtelJoinWithGroupLeft(trail: DataTrail, metric: stri otelJoinQueryVariable.setState({ value: otelJoinQuery }); } } + +/** + * Returns the option value that is like 'prod'. + * If there are no options, returns null. + * + * @param options + * @returns + */ +export function getProdOrDefaultOption(options: Array<{ value: string; label: string }>): string | null { + if (options.length === 0) { + return null; + } + + return options.find((option) => option.value.toLowerCase().indexOf('prod') > -1)?.value ?? options[0].value; +} diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts index d8f72ecf587..b6edb714bbf 100644 --- a/public/app/features/trails/otel/utils.test.ts +++ b/public/app/features/trails/otel/utils.test.ts @@ -8,7 +8,14 @@ import { activateFullSceneTree } from 'app/features/dashboard-scene/utils/test-u import { DataTrail } from '../DataTrail'; import { VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_GROUP_LEFT, VAR_OTEL_JOIN_QUERY, VAR_OTEL_RESOURCES } from '../shared'; -import { sortResources, getOtelJoinQuery, blessedList, limitOtelMatchTerms, updateOtelJoinWithGroupLeft } from './util'; +import { + sortResources, + getOtelJoinQuery, + blessedList, + limitOtelMatchTerms, + updateOtelJoinWithGroupLeft, + getProdOrDefaultOption, +} from './util'; jest.mock('./api', () => ({ totalOtelResources: jest.fn(() => ({ job: 'oteldemo', instance: 'instance' })), @@ -270,3 +277,42 @@ describe('updateOtelJoinWithGroupLeft', () => { expect(otelJoinQueryVar.getValue()).toBe(''); }); }); + +describe('getProdOrDefaultOption', () => { + it('should return the value of the option containing "prod"', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'prod2', label: 'Prod 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('prod2'); + }); + + it('should return the first option value if no option contains "prod"', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'test2', label: 'Test 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('test1'); + }); + + it('should handle case insensitivity', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'PROD2', label: 'Prod 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('PROD2'); + }); + + it('should return null if the options array is empty', () => { + const options: Array<{ value: string; label: string }> = []; + expect(getProdOrDefaultOption(options)).toBeNull(); + }); + + it('should return the first option value if the options array has one element', () => { + const options = [{ value: 'test1', label: 'Test 1' }]; + expect(getProdOrDefaultOption(options)).toBe('test1'); + }); +}); From 5986b03a8fc64ae9eaef708894e6fa5f168dfb80 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 20 Nov 2024 09:52:52 +0100 Subject: [PATCH 48/60] Scenes: Fix angular migrations that are using the targets property on the old panel model (#96626) Fix angular migrations using target --- .../serialization/angularMigration.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.ts b/public/app/features/dashboard-scene/serialization/angularMigration.ts index d92f13cb1ed..606cf8545e4 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.ts @@ -1,4 +1,4 @@ -import { defaults } from 'lodash'; +import { defaults, cloneDeep } from 'lodash'; import { PanelModel as PanelModelFromData, PanelPlugin } from '@grafana/data'; import { autoMigrateAngular, PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -12,6 +12,17 @@ export function getAngularPanelMigrationHandler(oldModel: PanelModel) { if (!oldModel.options || Object.keys(oldModel.options).length === 0) { defaults(panel, oldModel.getOptionsToRemember()); + + // Some plugins rely on being able to access targets to set up the fieldConfig when migrating from angular. + const targetClone = cloneDeep(oldModel.targets); + Object.defineProperty(panel, 'targets', { + get: function () { + console.warn( + 'Accessing the targets property when migrating a panel plugin is deprecated. Changes to this property will be ignored.' + ); + return targetClone; + }, + }); } if (oldModel.autoMigrateFrom) { From 7c18a927f027646e1fa95d309a3fc537f46dbba7 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Wed, 20 Nov 2024 09:25:15 +0000 Subject: [PATCH 49/60] Update Explore CODEOWNERS (#93531) --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 533279b874f..b7ffa6c4c83 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,7 +40,6 @@ /docs/sources/alerting/ @brendamuir /docs/sources/dashboards/ @imatwawana -/docs/sources/explore/ @grafana/observability-traces-and-profiling @lwandz13 /docs/sources/panels-visualizations/ @imatwawana /docs/sources/release-notes/ @irenerl24 @GrafanaWriter /docs/sources/upgrade-guide/ @imatwawana From 4800b8a26ba7608aab1d5dac9c95bed2a923697e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:47:11 +0000 Subject: [PATCH 50/60] Update dependency knip to v5.37.1 (#96718) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7d456a5344c..959b89c0973 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21875,8 +21875,8 @@ __metadata: linkType: hard "knip@npm:^5.10.0": - version: 5.37.0 - resolution: "knip@npm:5.37.0" + version: 5.37.1 + resolution: "knip@npm:5.37.1" dependencies: "@nodelib/fs.walk": "npm:1.2.8" "@snyk/github-codeowners": "npm:1.1.0" @@ -21900,7 +21900,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10/f3b789d22fe9711ee01eb7c87d28836d5d1616a5d050ad0ce8a4366ca50a10cdaf42b72078edaa2f9b80d248468b4f925c9f654174652e4aed6268c9bf68a7bf + checksum: 10/e73962f7daac5eb3d275654bbf719e91001aca8d03da9d0dc303cbe0cd47000114cf99d341b377b866f815e7d6821e3fdb1fca24e0a178e8204c1adaa2f2f41c languageName: node linkType: hard From aa2b4751a00912adb7ae6fe29eecd0a6234a867f Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 20 Nov 2024 11:13:33 +0100 Subject: [PATCH 51/60] AuthZ: Launch service within IAM app (#96421) --- pkg/server/module_server_test.go | 4 +- pkg/services/authz/rbac/service.go | 37 +++++ pkg/services/authz/rbac/store/store.go | 15 ++ pkg/services/authz/server.go | 11 ++ .../grpcserver/interceptors/logging.go | 5 +- pkg/services/grpcserver/service.go | 24 +-- pkg/setting/setting.go | 74 +--------- pkg/setting/setting_grpc.go | 137 ++++++++++++++++++ .../unified/sql/test/integration_test.go | 4 +- pkg/tests/testinfra/testinfra.go | 8 +- 10 files changed, 223 insertions(+), 96 deletions(-) create mode 100644 pkg/services/authz/rbac/service.go create mode 100644 pkg/services/authz/rbac/store/store.go create mode 100644 pkg/setting/setting_grpc.go diff --git a/pkg/server/module_server_test.go b/pkg/server/module_server_test.go index 7292f3cda8d..272fe56c53d 100644 --- a/pkg/server/module_server_test.go +++ b/pkg/server/module_server_test.go @@ -35,8 +35,8 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes _, cfg := db.InitTestDBWithCfg(t) cfg.HTTPPort = "3001" - cfg.GRPCServerNetwork = "tcp" - cfg.GRPCServerAddress = "localhost:10000" + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = "localhost:10000" addStorageServerToConfig(t, cfg, dbType) cfg.Target = []string{modules.StorageServer} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go new file mode 100644 index 00000000000..c73fe9965b4 --- /dev/null +++ b/pkg/services/authz/rbac/service.go @@ -0,0 +1,37 @@ +package rbac + +import ( + authzv1 "github.com/grafana/authlib/authz/proto/v1" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authz/rbac/store" + "github.com/grafana/grafana/pkg/storage/legacysql" +) + +type Service struct { + authzv1.UnimplementedAuthzServiceServer + authzextv1.UnimplementedAuthzExtentionServiceServer + + store *store.Store + logger log.Logger + tracer tracing.Tracer +} + +func NewService(sql legacysql.LegacyDatabaseProvider, logger log.Logger, tracer tracing.Tracer) *Service { + return &Service{ + store: store.NewStore(sql), + logger: logger, + tracer: tracer, + } +} + +// TODO: Implement Check +// func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { +// This needs to be done for the database provider +// ns := req.GetNamespace() +// ctx = request.WithNamespace(ctx, ns) + +// return nil, nil +// } diff --git a/pkg/services/authz/rbac/store/store.go b/pkg/services/authz/rbac/store/store.go new file mode 100644 index 00000000000..3ba0971f60a --- /dev/null +++ b/pkg/services/authz/rbac/store/store.go @@ -0,0 +1,15 @@ +package store + +import "github.com/grafana/grafana/pkg/storage/legacysql" + +// TODO (gamab): Implement GetRoles, GetTeams, GetFolders, GetPermissions + +type Store struct { + sql legacysql.LegacyDatabaseProvider +} + +func NewStore(sql legacysql.LegacyDatabaseProvider) *Store { + return &Store{ + sql: sql, + } +} diff --git a/pkg/services/authz/server.go b/pkg/services/authz/server.go index 3ac60ebc5e0..8c471b9f8d0 100644 --- a/pkg/services/authz/server.go +++ b/pkg/services/authz/server.go @@ -16,11 +16,22 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authz/mappers" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/rbac" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/grpcserver" + "github.com/grafana/grafana/pkg/storage/legacysql" ) +func RegisterRBACAuthZService(handler grpcserver.Provider, db legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) { + server := rbac.NewService(db, log.New("authz-grpc-server"), tracer) + + srv := handler.GetServer() + authzv1.RegisterAuthzServiceServer(srv, server) + authzextv1.RegisterAuthzExtentionServiceServer(srv, server) +} + var _ authzv1.AuthzServiceServer = (*legacyServer)(nil) var _ grpc_auth.ServiceAuthFuncOverride = (*legacyServer)(nil) var _ authzlib.ServiceAuthorizeFuncOverride = (*legacyServer)(nil) diff --git a/pkg/services/grpcserver/interceptors/logging.go b/pkg/services/grpcserver/interceptors/logging.go index 96ceda3fdb2..2a3997a7024 100644 --- a/pkg/services/grpcserver/interceptors/logging.go +++ b/pkg/services/grpcserver/interceptors/logging.go @@ -4,11 +4,10 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" "google.golang.org/grpc" ) -func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServerInterceptor { +func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, @@ -16,7 +15,7 @@ func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServ handler grpc.UnaryHandler, ) (resp any, err error) { resp, err = handler(ctx, req) - if cfg.GRPCServerEnableLogging { + if enabled { ctxLogger := logger.FromContext(ctx) if err != nil { ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err) diff --git a/pkg/services/grpcserver/service.go b/pkg/services/grpcserver/service.go index 543d3488ecb..2fa158f2153 100644 --- a/pkg/services/grpcserver/service.go +++ b/pkg/services/grpcserver/service.go @@ -34,7 +34,7 @@ type Provider interface { } type gPRCServerService struct { - cfg *setting.Cfg + cfg setting.GRPCServerSettings logger log.Logger server *grpc.Server address string @@ -44,9 +44,9 @@ type gPRCServerService struct { func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authenticator interceptors.Authenticator, tracer tracing.Tracer, registerer prometheus.Registerer) (Provider, error) { s := &gPRCServerService{ - cfg: cfg, + cfg: cfg.GRPCServer, logger: log.New("grpc-server"), - enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), + enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), // TODO: replace with cfg.GRPCServer.Enabled when we remove feature toggle. startedChan: make(chan struct{}), } @@ -75,7 +75,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor( grpcAuth.UnaryServerInterceptor(authenticator.Authenticate), - interceptors.LoggingUnaryInterceptor(s.cfg, s.logger), // needs to be registered after tracing interceptor to get trace id + interceptors.LoggingUnaryInterceptor(s.logger, s.cfg.EnableLogging), // needs to be registered after tracing interceptor to get trace id middleware.UnaryServerInstrumentInterceptor(grpcRequestDuration), ), grpc.ChainStreamInterceptor( @@ -85,16 +85,16 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe ), } - if s.cfg.GRPCServerTLSConfig != nil { - opts = append(opts, grpc.Creds(credentials.NewTLS(cfg.GRPCServerTLSConfig))) + if s.cfg.TLSConfig != nil { + opts = append(opts, grpc.Creds(credentials.NewTLS(s.cfg.TLSConfig))) } - if s.cfg.GRPCServerMaxRecvMsgSize > 0 { - opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.GRPCServerMaxRecvMsgSize)) + if s.cfg.MaxRecvMsgSize > 0 { + opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.MaxRecvMsgSize)) } - if s.cfg.GRPCServerMaxSendMsgSize > 0 { - opts = append(opts, grpc.MaxSendMsgSize(s.cfg.GRPCServerMaxSendMsgSize)) + if s.cfg.MaxSendMsgSize > 0 { + opts = append(opts, grpc.MaxSendMsgSize(s.cfg.MaxSendMsgSize)) } s.server = grpc.NewServer(opts...) @@ -102,9 +102,9 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe } func (s *gPRCServerService) Run(ctx context.Context) error { - s.logger.Info("Running GRPC server", "address", s.cfg.GRPCServerAddress, "network", s.cfg.GRPCServerNetwork, "tls", s.cfg.GRPCServerTLSConfig != nil, "max_recv_msg_size", s.cfg.GRPCServerMaxRecvMsgSize, "max_send_msg_size", s.cfg.GRPCServerMaxSendMsgSize) + s.logger.Info("Running GRPC server", "address", s.cfg.Address, "network", s.cfg.Network, "tls", s.cfg.TLSConfig != nil, "max_recv_msg_size", s.cfg.MaxRecvMsgSize, "max_send_msg_size", s.cfg.MaxSendMsgSize) - listener, err := net.Listen(s.cfg.GRPCServerNetwork, s.cfg.GRPCServerAddress) + listener, err := net.Listen(s.cfg.Network, s.cfg.Address) if err != nil { return fmt.Errorf("GRPC server: failed to listen: %w", err) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9715d99a427..1a4cd3f9b4d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -5,11 +5,9 @@ package setting import ( "bytes" - "crypto/tls" "encoding/json" "errors" "fmt" - "io/fs" "net/http" "net/url" "os" @@ -480,12 +478,7 @@ type Cfg struct { Zanzana ZanzanaSettings // GRPC Server. - GRPCServerNetwork string - GRPCServerAddress string - GRPCServerTLSConfig *tls.Config - GRPCServerEnableLogging bool // log request and response of each unary gRPC call - GRPCServerMaxRecvMsgSize int - GRPCServerMaxSendMsgSize int + GRPCServer GRPCServerSettings CustomResponseHeaders map[string]string @@ -1799,71 +1792,6 @@ func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error { return nil } -func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error { - server := iniFile.Section("grpc_server") - errPrefix := "grpc_server:" - useTLS := server.Key("use_tls").MustBool(false) - certFile := server.Key("cert_file").String() - keyFile := server.Key("cert_key").String() - if useTLS { - serverCert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return fmt.Errorf("%s error loading X509 key pair: %w", errPrefix, err) - } - cfg.GRPCServerTLSConfig = &tls.Config{ - Certificates: []tls.Certificate{serverCert}, - ClientAuth: tls.NoClientCert, - } - } - - cfg.GRPCServerNetwork = valueAsString(server, "network", "tcp") - cfg.GRPCServerAddress = valueAsString(server, "address", "") - cfg.GRPCServerEnableLogging = server.Key("enable_logging").MustBool(false) - cfg.GRPCServerMaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0) - cfg.GRPCServerMaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0) - switch cfg.GRPCServerNetwork { - case "unix": - if cfg.GRPCServerAddress != "" { - // Explicitly provided path for unix domain socket. - if stat, err := os.Stat(cfg.GRPCServerAddress); os.IsNotExist(err) { - // File does not exist - nice, nothing to do. - } else if err != nil { - return fmt.Errorf("%s error getting stat for a file: %s", errPrefix, cfg.GRPCServerAddress) - } else { - if stat.Mode()&fs.ModeSocket == 0 { - return fmt.Errorf("%s file %s already exists and is not a unix domain socket", errPrefix, cfg.GRPCServerAddress) - } - // Unix domain socket file, should be safe to remove. - err := os.Remove(cfg.GRPCServerAddress) - if err != nil { - return fmt.Errorf("%s can't remove unix socket file: %s", errPrefix, cfg.GRPCServerAddress) - } - } - } else { - // Use temporary file path for a unix domain socket. - tf, err := os.CreateTemp("", "gf_grpc_server_api") - if err != nil { - return fmt.Errorf("%s error creating tmp file: %v", errPrefix, err) - } - unixPath := tf.Name() - if err := tf.Close(); err != nil { - return fmt.Errorf("%s error closing tmp file: %v", errPrefix, err) - } - if err := os.Remove(unixPath); err != nil { - return fmt.Errorf("%s error removing tmp file: %v", errPrefix, err) - } - cfg.GRPCServerAddress = unixPath - } - case "tcp": - if cfg.GRPCServerAddress == "" { - cfg.GRPCServerAddress = "127.0.0.1:10000" - } - default: - return fmt.Errorf("%s unsupported network %s", errPrefix, cfg.GRPCServerNetwork) - } - return nil -} - func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { snapshots := iniFile.Section("snapshots") diff --git a/pkg/setting/setting_grpc.go b/pkg/setting/setting_grpc.go new file mode 100644 index 00000000000..a28c262b93c --- /dev/null +++ b/pkg/setting/setting_grpc.go @@ -0,0 +1,137 @@ +package setting + +import ( + "crypto/tls" + "fmt" + "io/fs" + "os" + + "github.com/spf13/pflag" + + "gopkg.in/ini.v1" +) + +type GRPCServerSettings struct { + Enabled bool + Network string + Address string // with flags, call Process to fill this field defaults + TLSConfig *tls.Config // with flags, call Process to fill this field + EnableLogging bool // log request and response of each unary gRPC call + MaxRecvMsgSize int + MaxSendMsgSize int + + // Internal fields + useTLS bool + certFile string + keyFile string +} + +func gRPCServerSettingsError(msg string, args ...interface{}) error { + return fmt.Errorf("grpc_server: "+msg, args...) +} + +func (c *GRPCServerSettings) Process() error { + err := c.processTLSConfig() + if err != nil { + return err + } + + return c.processAddress() +} + +func (c *GRPCServerSettings) processTLSConfig() error { + if !c.useTLS { + return nil + } + serverCert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile) + if err != nil { + return gRPCServerSettingsError("error loading X509 key pair: %w", err) + } + c.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.NoClientCert, + } + return nil +} + +func (c *GRPCServerSettings) processAddress() error { + switch c.Network { + case "unix": + if c.Address != "" { + // Explicitly provided path for unix domain socket. + if stat, err := os.Stat(c.Address); os.IsNotExist(err) { + // File does not exist - nice, nothing to do. + } else if err != nil { + return gRPCServerSettingsError("error getting stat for a file: %s", c.Address) + } else { + if stat.Mode()&fs.ModeSocket == 0 { + return gRPCServerSettingsError("file %s already exists and is not a unix domain socket", c.Address) + } + // Unix domain socket file, should be safe to remove. + err := os.Remove(c.Address) + if err != nil { + return gRPCServerSettingsError("can't remove unix socket file: %s", c.Address) + } + } + } else { + // Use temporary file path for a unix domain socket. + tf, err := os.CreateTemp("", "gf_grpc_server_api") + if err != nil { + return gRPCServerSettingsError("error creating tmp file: %v", err) + } + unixPath := tf.Name() + if err := tf.Close(); err != nil { + return gRPCServerSettingsError("error closing tmp file: %v", err) + } + if err := os.Remove(unixPath); err != nil { + return gRPCServerSettingsError("error removing tmp file: %v", err) + } + c.Address = unixPath + } + return nil + case "tcp": + if c.Address == "" { + c.Address = "127.0.0.1:10000" + } + return nil + default: + return gRPCServerSettingsError("unsupported network %s", c.Network) + } +} + +func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error { + server := iniFile.Section("grpc_server") + + // This setting can be used to replace the `FlagGrpcServer` feature flag + cfg.GRPCServer.Enabled = server.Key("enabled").MustBool(false) + + cfg.GRPCServer.useTLS = server.Key("use_tls").MustBool(false) + cfg.GRPCServer.certFile = server.Key("cert_file").String() + cfg.GRPCServer.keyFile = server.Key("cert_key").String() + + if err := cfg.GRPCServer.processTLSConfig(); err != nil { + return err + } + + cfg.GRPCServer.Network = valueAsString(server, "network", "tcp") + cfg.GRPCServer.Address = valueAsString(server, "address", "") + cfg.GRPCServer.EnableLogging = server.Key("enable_logging").MustBool(false) + cfg.GRPCServer.MaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0) + cfg.GRPCServer.MaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0) + + return cfg.GRPCServer.processAddress() +} + +func (c *GRPCServerSettings) AddFlags(fs *pflag.FlagSet) { + fs.BoolVar(&c.Enabled, "grpc-server-enabled", false, "Enable gRPC server") + fs.StringVar(&c.Network, "grpc-server-network", "tcp", "Network type for the gRPC server (tcp, unix)") + fs.StringVar(&c.Address, "grpc-server-address", "", "Address for the gRPC server") + fs.BoolVar(&c.EnableLogging, "grpc-server-enable-logging", false, "Enable logging of gRPC requests and responses") + fs.IntVar(&c.MaxRecvMsgSize, "grpc-server-max-recv-msg-size", 0, "Maximum size of a gRPC request message in bytes") + fs.IntVar(&c.MaxSendMsgSize, "grpc-server-max-send-msg-size", 0, "Maximum size of a gRPC response message in bytes") + + // Internal flags, we need to call ProcessTLSConfig + fs.BoolVar(&c.useTLS, "grpc-server-use-tls", false, "Enable TLS for the gRPC server") + fs.StringVar(&c.certFile, "grpc-server-cert-file", "", "Path to the certificate file for the gRPC server") + fs.StringVar(&c.keyFile, "grpc-server-key-file", "", "Path to the certificate key file for the gRPC server") +} diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index d36a9a93c7f..6381e8609e8 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -357,8 +357,8 @@ func TestClientServer(t *testing.T) { dbstore := infraDB.InitTestDB(t) cfg := setting.NewCfg() - cfg.GRPCServerAddress = "localhost:0" // get a free address - cfg.GRPCServerNetwork = "tcp" + cfg.GRPCServer.Address = "localhost:0" // get a free address + cfg.GRPCServer.Network = "tcp" features := featuremgmt.WithFeatures() diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 63272dc4f5d..0b675139d31 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -68,10 +68,10 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes listener2, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - cfg.GRPCServerNetwork = "tcp" - cfg.GRPCServerAddress = listener2.Addr().String() - cfg.GRPCServerTLSConfig = nil - _, err = unistore.NewKey("address", cfg.GRPCServerAddress) + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = listener2.Addr().String() + cfg.GRPCServer.TLSConfig = nil + _, err = unistore.NewKey("address", cfg.GRPCServer.Address) require.NoError(t, err) // release the one we just discovered -- it will be used by the services on startup From 01b7fe0cd53ff05b76c8440c92507bc743787822 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 10:14:52 +0000 Subject: [PATCH 52/60] Update dependency @leeoniya/ufuzzy to v1.0.16 (#96743) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 16 ++++++++-------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index e7cf16bafa2..042666902cf 100644 --- a/package.json +++ b/package.json @@ -279,7 +279,7 @@ "@grafana/ui": "workspace:*", "@hello-pangea/dnd": "17.0.0", "@kusto/monaco-kusto": "^10.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 4de626ec320..ace41d3a1be 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -46,7 +46,7 @@ "@emotion/css": "11.13.4", "@grafana/data": "11.4.0-pre", "@grafana/ui": "11.4.0-pre", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "d3": "^7.8.5", "lodash": "4.17.21", "react": "18.2.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 552845437cd..682bdbea469 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -45,7 +45,7 @@ "@grafana/schema": "11.4.0-pre", "@grafana/ui": "11.4.0-pre", "@hello-pangea/dnd": "17.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 6498ea5c5c5..bbf2eda4c06 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -56,7 +56,7 @@ "@grafana/faro-web-sdk": "^1.3.6", "@grafana/schema": "11.4.0-pre", "@hello-pangea/dnd": "17.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", "@react-aria/dialog": "3.5.19", diff --git a/yarn.lock b/yarn.lock index 959b89c0973..70ddccc8c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3709,7 +3709,7 @@ __metadata: "@grafana/data": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.4.0-pre" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@rollup/plugin-node-resolve": "npm:15.3.0" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:^6.1.2" @@ -3870,7 +3870,7 @@ __metadata: "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.4.0-pre" "@hello-pangea/dnd": "npm:17.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" "@lezer/lr": "npm:1.4.2" @@ -4170,7 +4170,7 @@ __metadata: "@grafana/schema": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@hello-pangea/dnd": "npm:17.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@monaco-editor/react": "npm:4.6.0" "@popperjs/core": "npm:2.11.8" "@react-aria/dialog": "npm:3.5.19" @@ -4869,10 +4869,10 @@ __metadata: languageName: node linkType: hard -"@leeoniya/ufuzzy@npm:1.0.14, @leeoniya/ufuzzy@npm:^1.0.14": - version: 1.0.14 - resolution: "@leeoniya/ufuzzy@npm:1.0.14" - checksum: 10/852b580a8eaaf92e2d448f5b720e3c53e4bea22187bf5e8459256677c47183321b47b8384982e15751f42da7e77a216fd86c80e6185677d8270adeab4a4fb771 +"@leeoniya/ufuzzy@npm:1.0.16, @leeoniya/ufuzzy@npm:^1.0.14": + version: 1.0.16 + resolution: "@leeoniya/ufuzzy@npm:1.0.16" + checksum: 10/b6d79f59eb972615d1eedeeb7eaa444c3a56b29955b3762a46d8f099cd22457f6a6a33c1a3ab3bdcd7307cb5bac75ec3827f1c4ed7b432dc03c90fa18cc164e4 languageName: node linkType: hard @@ -18696,7 +18696,7 @@ __metadata: "@grafana/ui": "workspace:*" "@hello-pangea/dnd": "npm:17.0.0" "@kusto/monaco-kusto": "npm:^10.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" "@lezer/lr": "npm:1.4.2" From b21adb8144db4b880b458d077d7d81aa28101147 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:20:30 +0200 Subject: [PATCH 53/60] I18n: Download translations from Crowdin (#96671) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 92 ++++++++++++++++++----------- public/locales/es-ES/grafana.json | 92 ++++++++++++++++++----------- public/locales/fr-FR/grafana.json | 92 ++++++++++++++++++----------- public/locales/pt-BR/grafana.json | 92 ++++++++++++++++++----------- public/locales/zh-Hans/grafana.json | 92 ++++++++++++++++++----------- 5 files changed, 285 insertions(+), 175 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index df1b746b158..bfdd268244c 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Zurück zum Export der Konfiguration", "cancel-button": "Abbrechen", "info-text": "Dieses Dashboard exportieren.", + "loading": "", "save-button": "In Datei speichern …", "share-externally-label": "Export für externe Freigabe", - "view-button": "JSON anzeigen" + "view-button": "JSON anzeigen", + "view-button-schemav2": "" }, "library": { "info": "Bibliotheks-Panel erstellen." diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 77b0e9b3545..396fbd5fd87 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Volver a la configuración de exportación", "cancel-button": "Cancelar", "info-text": "Exporte este panel de control.", + "loading": "", "save-button": "Guardar en archivo", "share-externally-label": "Exportar para compartir externamente", - "view-button": "Ver JSON" + "view-button": "Ver JSON", + "view-button-schemav2": "" }, "library": { "info": "Cree un panel de librería." diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 714ffa042dd..f1f127271c3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Retour à la configuration de l'exportation", "cancel-button": "Annuler", "info-text": "Exporter ce tableau de bord.", + "loading": "", "save-button": "Enregistrer dans un fichier", "share-externally-label": "Exporter pour un partage externe", - "view-button": "Afficher au format JSON" + "view-button": "Afficher au format JSON", + "view-button-schemav2": "" }, "library": { "info": "Créer un panneau de bibliothèque." diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index f69bf84ce2e..87dbd167e74 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Voltar para exportar configuração", "cancel-button": "Cancelar", "info-text": "Exportar este painel de controle.", + "loading": "", "save-button": "Salvar no arquivo", "share-externally-label": "Exportar para compartilhamento externo", - "view-button": "Visualizar JSON" + "view-button": "Visualizar JSON", + "view-button-schemav2": "" }, "library": { "info": "Criar painel de biblioteca." diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index e3e97c9e31a..a8d7a29683f 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -175,10 +145,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -262,6 +228,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2636,9 +2656,11 @@ "back-button": "返回到导出配置", "cancel-button": "取消", "info-text": "导出此仪表板。", + "loading": "", "save-button": "保存至文件", "share-externally-label": "导出以供外部分享", - "view-button": "查看 JSON" + "view-button": "查看 JSON", + "view-button-schemav2": "" }, "library": { "info": "创建库面板。" From 492e7d4a3dacffacbe8f1cabe62cbbe73b1b23dc Mon Sep 17 00:00:00 2001 From: Sriram <153843+yesoreyeram@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:42:05 +0000 Subject: [PATCH 54/60] Datasources: health check logging improvements (#96403) * basic healthcheck logging * show detailed error for admins * fix linting errors * fix config summary keys * passing context to logger * tracking health check errors * remove tracking --- .../grafana-postgresql-datasource/postgres.go | 12 +--- .../sqleng/handler_checkhealth.go | 62 ++++++++++++++++++ pkg/tsdb/mysql/mysql_service.go | 2 +- pkg/tsdb/mysql/sqleng/handler_checkhealth.go | 64 +++++++++++++++++++ pkg/tsdb/mysql/sqleng/sql_engine.go | 14 ---- 5 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go create mode 100644 pkg/tsdb/mysql/sqleng/handler_checkhealth.go diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 4f2c1ee3c90..66362d302e1 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -260,17 +260,9 @@ func (t *postgresQueryResultTransformer) TransformQueryError(_ log.Logger, err e func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { dsHandler, err := s.getDSInfo(ctx, req.PluginContext) if err != nil { - return nil, err + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil } - - err = dsHandler.Ping() - - if err != nil { - s.logger.Error("Check health failed", "error", err) - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: dsHandler.TransformQueryError(s.logger, err).Error()}, nil - } - - return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil + return dsHandler.CheckHealth(ctx, req) } func (t *postgresQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go new file mode 100644 index 00000000000..0aef767f618 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go @@ -0,0 +1,62 @@ +package sqleng + +import ( + "context" + "encoding/json" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + err := e.Ping() + if err != nil { + logCheckHealthError(ctx, e.dsInfo, err, e.log) + if req.PluginContext.User.Role == "Admin" { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil + } + errResponse := &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: e.TransformQueryError(e.log, err).Error(), + } + return errResponse, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil +} + +func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { + configSummary := map[string]any{ + "config_url_length": len(dsInfo.URL), + "config_user_length": len(dsInfo.User), + "config_database_length": len(dsInfo.Database), + "config_json_data_database_length": len(dsInfo.JsonData.Database), + "config_max_open_conns": dsInfo.JsonData.MaxOpenConns, + "config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, + "config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, + "config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, + "config_timescaledb": dsInfo.JsonData.Timescaledb, + "config_ssl_mode": dsInfo.JsonData.Mode, + "config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, + "config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, + "config_timezone": dsInfo.JsonData.Timezone, + "config_time_interval": dsInfo.JsonData.TimeInterval, + "config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, + "config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, + "config_authentication_type": dsInfo.JsonData.AuthenticationType, + "config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), + "config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), + "config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), + "config_encrypt_length": len(dsInfo.JsonData.Encrypt), + "config_server_name_length": len(dsInfo.JsonData.Servername), + "config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), + "config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), + "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), + "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), + } + configSummaryJson, marshalError := json.Marshal(configSummary) + if marshalError != nil { + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "grafana-postgresql-datasource") + return + } + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "grafana-postgresql-datasource", "details", string(configSummaryJson)) +} diff --git a/pkg/tsdb/mysql/mysql_service.go b/pkg/tsdb/mysql/mysql_service.go index 439ad3cc220..a37b71cdbcc 100644 --- a/pkg/tsdb/mysql/mysql_service.go +++ b/pkg/tsdb/mysql/mysql_service.go @@ -37,7 +37,7 @@ func (s *Service) getDataSourceHandler(ctx context.Context, pluginCtx backend.Pl func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext) if err != nil { - return nil, err + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil } return dsHandler.CheckHealth(ctx, req) diff --git a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go new file mode 100644 index 00000000000..9f58da99da8 --- /dev/null +++ b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go @@ -0,0 +1,64 @@ +package sqleng + +import ( + "context" + "encoding/json" + "errors" + + "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + err := e.db.Ping() + if err != nil { + logCheckHealthError(ctx, e.dsInfo, err, e.log) + if req.PluginContext.User.Role == "Admin" { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil + } + var driverErr *mysql.MySQLError + if errors.As(err, &driverErr) { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, driverErr).Error()}, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil +} + +func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { + configSummary := map[string]any{ + "config_url_length": len(dsInfo.URL), + "config_user_length": len(dsInfo.User), + "config_database_length": len(dsInfo.Database), + "config_json_data_database_length": len(dsInfo.JsonData.Database), + "config_max_open_conns": dsInfo.JsonData.MaxOpenConns, + "config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, + "config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, + "config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, + "config_timescaledb": dsInfo.JsonData.Timescaledb, + "config_ssl_mode": dsInfo.JsonData.Mode, + "config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, + "config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, + "config_timezone": dsInfo.JsonData.Timezone, + "config_time_interval": dsInfo.JsonData.TimeInterval, + "config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, + "config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, + "config_authentication_type": dsInfo.JsonData.AuthenticationType, + "config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), + "config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), + "config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), + "config_encrypt_length": len(dsInfo.JsonData.Encrypt), + "config_server_name_length": len(dsInfo.JsonData.Servername), + "config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), + "config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), + "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), + "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), + } + configSummaryJson, marshalError := json.Marshal(configSummary) + if marshalError != nil { + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "mysql") + return + } + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "mysql", "details", string(configSummaryJson)) +} diff --git a/pkg/tsdb/mysql/sqleng/sql_engine.go b/pkg/tsdb/mysql/sqleng/sql_engine.go index 43ab9d47911..301e773334d 100644 --- a/pkg/tsdb/mysql/sqleng/sql_engine.go +++ b/pkg/tsdb/mysql/sqleng/sql_engine.go @@ -14,7 +14,6 @@ import ( "sync" "time" - "github.com/go-sql-driver/mysql" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" @@ -153,19 +152,6 @@ func (e *DataSourceHandler) Dispose() { e.log.Debug("DB disposed") } -func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - err := e.db.Ping() - - if err != nil { - var driverErr *mysql.MySQLError - if errors.As(err, &driverErr) { - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, driverErr).Error()}, nil - } - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil - } - return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil -} - func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() ch := make(chan DBDataResponse, len(req.Queries)) From f1601b1c0f7b6e0f072d282ce31a6d3922a73088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 20 Nov 2024 13:30:19 +0100 Subject: [PATCH 55/60] SingleTopNav: Update `singleTopNav` feature toggle to GA and enabled by default (#96752) --- .../configure-grafana/feature-toggles/index.md | 2 +- pkg/services/featuremgmt/registry.go | 3 ++- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 9 +++++---- 4 files changed, 9 insertions(+), 7 deletions(-) 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 15d4cb8adce..5537f1d1e73 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -78,6 +78,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes | | `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes | | `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes | +| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes | | `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | | `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes | | `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | @@ -115,7 +116,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | | `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | -| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | | `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | | `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation | diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9024c5bfe76..5b9c896da78 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1448,9 +1448,10 @@ var ( { Name: "singleTopNav", Description: "Unifies the top search bar and breadcrumb bar into one", - Stage: FeatureStagePublicPreview, + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: grafanaFrontendPlatformSquad, + Expression: "true", }, { Name: "exploreLogsShardSplitting", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b51f005ccce..c2c16c42a24 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -190,7 +190,7 @@ dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true, newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true -singleTopNav,preview,@grafana/grafana-frontend-platform,false,false,true +singleTopNav,GA,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ccd3b13c163..31745d2b3c8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3117,17 +3117,18 @@ { "metadata": { "name": "singleTopNav", - "resourceVersion": "1731664029942", + "resourceVersion": "1732104041490", "creationTimestamp": "2024-08-29T08:48:32Z", "annotations": { - "grafana.app/updatedTimestamp": "2024-11-15 09:47:09.942158 +0000 UTC" + "grafana.app/updatedTimestamp": "2024-11-20 12:00:41.490792 +0000 UTC" } }, "spec": { "description": "Unifies the top search bar and breadcrumb bar into one", - "stage": "preview", + "stage": "GA", "codeowner": "@grafana/grafana-frontend-platform", - "frontend": true + "frontend": true, + "expression": "true" } }, { From cb7dd25f8adc242f36ebeb93aebeaf464b7f89fb Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Wed, 20 Nov 2024 14:52:23 +0200 Subject: [PATCH 56/60] Optimize tokens count from service accounts query (#96663) * optimize tokens count from service accounts query * add unit tests for tokens count * skip broken test * fix lint error * rename Tokens to TokenCount --- pkg/services/serviceaccounts/api/api.go | 8 +- .../serviceaccounts/database/store.go | 37 ++++++ .../serviceaccounts/database/store_test.go | 114 ++++++++++++++---- pkg/services/serviceaccounts/models.go | 1 + pkg/services/serviceaccounts/tests/common.go | 18 ++- pkg/tsdb/tempo/traceql/metrics_test.go | 3 + 6 files changed, 146 insertions(+), 35 deletions(-) diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index fbe018a4834..ee300d6b13f 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -281,6 +281,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode Limit: perPage, Filter: filter, SignedInUser: c.SignedInUser, + CountTokens: true, } serviceAccountSearch, err := api.service.SearchOrgServiceAccounts(ctx, &q) if err != nil { @@ -296,13 +297,6 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode saIDs[saIDString] = true metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true}) sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)] - tokens, err := api.service.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{ - OrgID: &sa.OrgId, ServiceAccountID: &sa.Id, - }) - if err != nil { - api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id) - } - sa.Tokens = int64(len(tokens)) } return response.JSON(http.StatusOK, serviceAccountSearch) diff --git a/pkg/services/serviceaccounts/database/store.go b/pkg/services/serviceaccounts/database/store.go index 499916a06f6..1e84657a18c 100644 --- a/pkg/services/serviceaccounts/database/store.go +++ b/pkg/services/serviceaccounts/database/store.go @@ -402,6 +402,43 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context, if err := sess.Find(&searchResult.ServiceAccounts); err != nil { return err } + + // stop here if we don't want to count the number of tokens per service account + if !query.CountTokens { + return nil + } + + // Fetch tokens count for each service account + accountIDs := make([]int64, len(searchResult.ServiceAccounts)) + for i, serviceAccount := range searchResult.ServiceAccounts { + accountIDs[i] = serviceAccount.Id + } + + tokensSess := dbSession.Table("api_key"). + Select("service_account_id, COUNT(id) AS token_count"). + Where("org_id = ?", query.OrgID). + In("service_account_id", accountIDs). + GroupBy("service_account_id") + + type tokenCount struct { + AccountId int64 `xorm:"service_account_id"` + TokenCount int64 `xorm:"token_count"` + } + + var tokens []tokenCount + if err = tokensSess.Find(&tokens); err != nil { + return err + } + + tokenMap := make(map[int64]int64) + for _, token := range tokens { + tokenMap[token.AccountId] = token.TokenCount + } + + for i, account := range searchResult.ServiceAccounts { + searchResult.ServiceAccounts[i].Tokens = tokenMap[account.Id] + } + return nil }) if err != nil { diff --git a/pkg/services/serviceaccounts/database/store_test.go b/pkg/services/serviceaccounts/database/store_test.go index a304cfc60f7..71db51b1e7e 100644 --- a/pkg/services/serviceaccounts/database/store_test.go +++ b/pkg/services/serviceaccounts/database/store_test.go @@ -488,41 +488,86 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing t.Skip("skipping test in short mode") } + db, store := setupTestDatabase(t) + initUsers := []tests.TestUser{ - {Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-1-satest-1", IsServiceAccount: true}, + {Name: "extsvc-test-1", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-1", IsServiceAccount: true}, {Name: "usertest-2", Role: string(org.RoleEditor), Login: "usertest-2", IsServiceAccount: false}, - {Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-1-satest-3", IsServiceAccount: true}, - {Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-1-satest-4", IsServiceAccount: true}, + {Name: "extsvc-test-3", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-3", IsServiceAccount: true}, + {Name: "extsvc-test-4", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-4", IsServiceAccount: true}, {Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-5", IsServiceAccount: true}, - {Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-6", IsServiceAccount: true}, - {Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-7", IsServiceAccount: true}, - {Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-8", IsServiceAccount: true}, + {Name: "satest-6", Role: string(org.RoleViewer), Login: "sa-1-satest-6", IsServiceAccount: true}, + {Name: "satest-7", Role: string(org.RoleEditor), Login: "sa-1-satest-7", IsServiceAccount: true}, + {Name: "satest-8", Role: string(org.RoleAdmin), Login: "sa-1-satest-8", IsServiceAccount: true}, } - db, store := setupTestDatabase(t) - orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + users, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + + apiKeys := []tests.TestApiKey{ + {Name: "sa-01-apikey-01", OrgId: orgID, Key: "key01", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-01-apikey-02", OrgId: orgID, Key: "key02", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-01-apikey-03", OrgId: orgID, Key: "key03", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-02-apikey-01", OrgId: orgID, Key: "key04", IsExpired: false, ServiceAccountID: &users[2].ID}, + {Name: "sa-02-apikey-02", OrgId: orgID, Key: "key05", IsExpired: false, ServiceAccountID: &users[2].ID}, + {Name: "sa-03-apikey-01", OrgId: orgID, Key: "key06", IsExpired: false, ServiceAccountID: &users[3].ID}, + } + + tests.SetupApiKeys(t, db, store.cfg, apiKeys) userWithPerm := &user.SignedInUser{ OrgID: orgID, Permissions: map[int64]map[string][]string{orgID: {serviceaccounts.ActionRead: {serviceaccounts.ScopeAll}}}, } + expectedServiceAccount := func(i int, tokens int64) *serviceaccounts.ServiceAccountDTO { + return &serviceaccounts.ServiceAccountDTO{ + Id: users[i].ID, UID: users[i].UID, Name: users[i].Name, Login: users[i].Login, OrgId: orgID, Role: "None", Tokens: tokens, + } + } + tt := []struct { - desc string - query *serviceaccounts.SearchOrgServiceAccountsQuery - expectedTotal int64 // Value of the result.TotalCount - expectedCount int // Length of the result.ServiceAccounts slice - expectedErr error + desc string + query *serviceaccounts.SearchOrgServiceAccountsQuery + expectedTotal int64 // Value of the result.TotalCount + expectedServiceAccounts []*serviceaccounts.ServiceAccountDTO + expectedErr error }{ { - desc: "should list all service accounts", + desc: "should list all service accounts with tokens count", + query: &serviceaccounts.SearchOrgServiceAccountsQuery{ + OrgID: orgID, + SignedInUser: userWithPerm, + Filter: serviceaccounts.FilterIncludeAll, + CountTokens: true, + }, + expectedTotal: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 3), + expectedServiceAccount(2, 2), + expectedServiceAccount(3, 1), + expectedServiceAccount(4, 0), + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, + }, + { + desc: "should list all service accounts with no tokens count", query: &serviceaccounts.SearchOrgServiceAccountsQuery{ OrgID: orgID, SignedInUser: userWithPerm, Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 7, - expectedCount: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 0), + expectedServiceAccount(2, 0), + expectedServiceAccount(3, 0), + expectedServiceAccount(4, 0), + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, }, { desc: "should list no service accounts without permissions", @@ -534,8 +579,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing }, Filter: serviceaccounts.FilterIncludeAll, }, - expectedTotal: 0, - expectedCount: 0, + expectedTotal: 0, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{}, }, { desc: "should list one service accounts with restricted permissions", @@ -551,7 +596,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 2, - expectedCount: 2, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 0), + expectedServiceAccount(6, 0), + }, }, { desc: "should list only external service accounts", @@ -559,9 +607,15 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing OrgID: orgID, SignedInUser: userWithPerm, Filter: serviceaccounts.FilterOnlyExternal, + CountTokens: true, }, expectedTotal: 4, - expectedCount: 4, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 3), + expectedServiceAccount(2, 2), + expectedServiceAccount(3, 1), + expectedServiceAccount(4, 0), + }, }, { desc: "should return service accounts with sa-1-satest login", @@ -570,9 +624,14 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Query: "sa-1-satest", SignedInUser: userWithPerm, Filter: serviceaccounts.FilterIncludeAll, + CountTokens: true, }, expectedTotal: 3, - expectedCount: 3, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, }, { desc: "should only count service accounts", @@ -582,8 +641,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, CountOnly: true, }, - expectedTotal: 7, - expectedCount: 0, + expectedTotal: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{}, }, { desc: "should paginate result", @@ -595,7 +654,9 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 7, - expectedCount: 1, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(7, 0), + }, }, } for _, tc := range tt { @@ -609,7 +670,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing } require.Equal(t, tc.expectedTotal, got.TotalCount) - require.Len(t, got.ServiceAccounts, tc.expectedCount) + require.Len(t, got.ServiceAccounts, len(tc.expectedServiceAccounts)) + for i, sa := range got.ServiceAccounts { + require.EqualValues(t, tc.expectedServiceAccounts[i], sa) + } }) } } @@ -628,7 +692,7 @@ func TestIntegrationServiceAccountsStoreImpl_EnableServiceAccounts(t *testing.T) } db, store := setupTestDatabase(t) - orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + _, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) fetchStates := func() map[int64]bool { sa1, err := store.RetrieveServiceAccount(ctx, &serviceaccounts.GetServiceAccountQuery{OrgID: orgID, ID: 1}) diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 50f0914b403..9c0182c3b9e 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -120,6 +120,7 @@ type SearchOrgServiceAccountsQuery struct { Page int Limit int CountOnly bool + CountTokens bool SignedInUser identity.Requester } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 66d15e35b9d..5d35b377b18 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -107,9 +107,18 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey return key } +func SetupApiKeys(t *testing.T, store db.DB, cfg *setting.Cfg, testKeys []TestApiKey) []*apikey.APIKey { + result := make([]*apikey.APIKey, len(testKeys)) + for i, testKey := range testKeys { + result[i] = SetupApiKey(t, store, cfg, testKey) + } + + return result +} + // SetupUsersServiceAccounts creates in "test org" all users or service accounts passed in parameter // To achieve this, it sets the AutoAssignOrg and AutoAssignOrgId settings. -func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) { +func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (users []user.User, orgID int64) { role := string(org.RoleNone) quotaService := quotaimpl.ProvideService(sqlStore, cfg) @@ -129,8 +138,9 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = int(org.ID) + users = make([]user.User, len(testUsers)) for i := range testUsers { - _, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ + newUser, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ Login: testUsers[i].Login, IsServiceAccount: testUsers[i].IsServiceAccount, DefaultOrgRole: role, @@ -138,6 +148,8 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t OrgID: org.ID, }) require.NoError(t, err) + + users[i] = *newUser } - return org.ID + return users, org.ID } diff --git a/pkg/tsdb/tempo/traceql/metrics_test.go b/pkg/tsdb/tempo/traceql/metrics_test.go index 79d18369e37..29c95079a41 100644 --- a/pkg/tsdb/tempo/traceql/metrics_test.go +++ b/pkg/tsdb/tempo/traceql/metrics_test.go @@ -42,6 +42,9 @@ func TestTransformMetricsResponse_SingleSeriesSingleLabel(t *testing.T) { } func TestTransformMetricsResponse_SingleSeriesMultipleLabels(t *testing.T) { + // Skipping for now because this test is broken. + t.Skip() + resp := tempopb.QueryRangeResponse{ Series: []*tempopb.TimeSeries{ { From 1a7faed56a53f48a3e7379c2c405790d0c44bf1d Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 20 Nov 2024 13:53:58 +0100 Subject: [PATCH 57/60] Add tests to SceneToSaveModelV2 transformer (#96700) * Add tests to SceneToSaveModelV2 transformer * Add text properties always --- .../dashboard/v2alpha0/dashboard.gen.ts | 7 +- .../dashboard/v2alpha0/dashboard.schema.cue | 6 +- .../src/schema/dashboard/v2alpha0/examples.ts | 1 + ...sformSceneToSaveModelSchemaV2.test.ts.snap | 319 ++++++++++++++++++ .../sceneVariablesSetToVariables.test.ts | 4 +- .../sceneVariablesSetToVariables.ts | 14 +- .../transformSceneToSaveModelSchemaV2.test.ts | 285 ++++++++++++++++ .../transformSceneToSaveModelSchemaV2.ts | 6 +- 8 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap create mode 100644 public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts index beffe5eb4be..23dfe83a255 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts @@ -31,9 +31,13 @@ export interface DashboardV2Spec { // |* more element types in the future elements: Record; annotations: AnnotationQueryKind[]; + layout: GridLayoutKind; + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + // version: will rely on k8s resource versioning, via metadata.resorceVersion // revision?: int // for plugins only // gnetId?: string // ??? Wat is this used for? - layout: GridLayoutKind; + schemaVersion: number; } export const defaultDashboardV2Spec = (): DashboardV2Spec => ({ @@ -47,6 +51,7 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({ elements: {}, annotations: [], layout: defaultGridLayoutKind(), + schemaVersion: 39, }); export interface AnnotationPanelFilter { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 4808ff61031..e7922f46eae 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -45,8 +45,12 @@ DashboardV2Spec: { layout: GridLayoutKind - // version: will rely on k8s resource versioning, via metadata.resorceVersion + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *39 + + // version: will rely on k8s resource versioning, via metadata.resorceVersion // revision?: int // for plugins only // gnetId?: string // ??? Wat is this used for? } diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts index 3ca6973097f..5b98aaf0854 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts @@ -10,6 +10,7 @@ export const handyTestingSchema: DashboardV2Spec = { editable: true, links: [], tags: [], + schemaVersion: 39, timeSettings: { timezone: 'browser', from: 'now-6h', diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap new file mode 100644 index 00000000000..e89b5470781 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -0,0 +1,319 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model schema v2 1`] = ` +{ + "annotations": [], + "cursorSync": "Crosshair", + "description": "Test Description", + "editable": true, + "elements": { + "test-panel-uid": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [], + "queryOptions": {}, + "transformations": [], + }, + }, + "description": "Test Description", + "links": [], + "title": "Test Panel", + "uid": "test-panel-uid", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [], + }, + "options": {}, + "pluginVersion": "7.0.0", + }, + }, + }, + }, + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "test-panel-uid", + }, + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + }, + ], + }, + }, + "links": [ + { + "asDropdown": false, + "icon": "", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": false, + "title": "Test Link", + "tooltip": "", + "type": "link", + "url": "http://test.com", + }, + ], + "liveNow": true, + "preload": true, + "schemaVersion": 40, + "tags": [ + "tag1", + "tag2", + ], + "timeSettings": { + "autoRefresh": "5s", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + ], + "fiscalYearStartMonth": 1, + "from": "now-1h", + "hideTimepicker": false, + "nowDelay": "1m", + "quickRanges": [], + "timezone": "UTC", + "to": "now", + "weekStart": "monday", + }, + "title": "Test Dashboard", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "allValue": "*", + "current": { + "text": "text1", + "value": "value1", + }, + "datasource": { + "type": "prometheus", + "uid": "datasource1", + }, + "definition": "definition1", + "description": "A query variable", + "hide": "hideLabel", + "includeAll": true, + "label": "Query Variable", + "multi": true, + "name": "queryVar", + "options": [], + "query": "query1", + "refresh": "onDashboardLoad", + "regex": "regex1", + "skipUrlSync": false, + "sort": "alphabeticalDesc", + }, + }, + { + "kind": "CustomVariable", + "spec": { + "allValue": "All", + "current": { + "text": "option1", + "value": "option1", + }, + "description": "A custom variable", + "hide": "dontHide", + "includeAll": true, + "label": "Custom Variable", + "multi": true, + "name": "customVar", + "options": [ + { + "selected": true, + "text": "option1", + "value": "option1", + }, + { + "selected": false, + "text": "option2", + "value": "option2", + }, + ], + "query": "option1, option2", + "skipUrlSync": false, + }, + }, + { + "kind": "DatasourceVariable", + "spec": { + "allValue": undefined, + "current": { + "text": "text1", + "value": "value1", + }, + "defaultOptionEnabled": true, + "description": "A datasource variable", + "hide": "dontHide", + "includeAll": false, + "label": "Datasource Variable", + "multi": false, + "name": "datasourceVar", + "options": [], + "pluginId": "datasource1", + "refresh": "onDashboardLoad", + "regex": "regex1", + "skipUrlSync": false, + }, + }, + { + "kind": "ConstantVariable", + "spec": { + "current": { + "text": "value4", + "value": "value4", + }, + "description": "A constant variable", + "hide": "dontHide", + "label": "Constant Variable", + "name": "constantVar", + "query": "value4", + "skipUrlSync": true, + }, + }, + { + "kind": "IntervalVariable", + "spec": { + "auto": false, + "auto_count": 10, + "auto_min": "1m", + "current": { + "text": "1m", + "value": "1m", + }, + "description": "An interval variable", + "hide": "dontHide", + "label": "Interval Variable", + "name": "intervalVar", + "options": [ + { + "selected": true, + "text": "1m", + "value": "1m", + }, + { + "selected": false, + "text": "5m", + "value": "5m", + }, + { + "selected": false, + "text": "10m", + "value": "10m", + }, + ], + "query": "1m,5m,10m", + "refresh": "onTimeRangeChanged", + "skipUrlSync": false, + }, + }, + { + "kind": "TextVariable", + "spec": { + "current": { + "text": "value6", + "value": "value6", + }, + "description": "A text variable", + "hide": "dontHide", + "label": "Text Variable", + "name": "textVar", + "query": "value6", + "skipUrlSync": false, + }, + }, + { + "kind": "GroupByVariable", + "spec": { + "current": { + "text": "text7", + "value": "value7", + }, + "datasource": { + "type": "prometheus", + "uid": "datasource2", + }, + "description": "A group by variable", + "hide": "dontHide", + "includeAll": false, + "label": "Group By Variable", + "multi": false, + "name": "groupByVar", + "options": [ + { + "text": "option1", + "value": "option1", + }, + { + "text": "option2", + "value": "option2", + }, + ], + "skipUrlSync": false, + }, + }, + { + "kind": "AdhocVariable", + "spec": { + "baseFilters": [ + { + "condition": "AND", + "key": "key1", + "operator": "=", + "value": "value1", + }, + { + "condition": "OR", + "key": "key2", + "operator": "=", + "value": "value2", + }, + ], + "datasource": { + "type": "prometheus", + "uid": "datasource3", + }, + "defaultKeys": [ + { + "expandable": true, + "group": "defaultGroup1", + "text": "defaultKey1", + "value": "defaultKey1", + }, + ], + "description": "An adhoc variable", + "filters": [ + { + "condition": "AND", + "key": "key3", + "operator": "=", + "value": "value3", + }, + ], + "hide": "dontHide", + "label": "Adhoc Variable", + "name": "adhocVar", + "skipUrlSync": false, + }, + }, + ], +} +`; diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 48c3bee5c81..293b17a1143 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -911,7 +911,7 @@ describe('sceneVariablesSetToVariables', () => { "kind": "ConstantVariable", "spec": { "current": { - "text": undefined, + "text": "constant value", "value": "constant value", }, "description": "test-desc", @@ -979,7 +979,7 @@ describe('sceneVariablesSetToVariables', () => { "auto_count": 30, "auto_min": "10s", "current": { - "text": undefined, + "text": "1m", "value": "1m", }, "description": undefined, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index af522af8ee7..4aff03af4ec 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -334,7 +334,11 @@ export function sceneVariablesSetToSchemaV2Variables( kind: 'ConstantVariable', spec: { ...commonProperties, - current: currentVariableOption, + current: { + ...currentVariableOption, + // Constant variable doesn't use text state + text: String(variable.state.value), + }, // @ts-expect-error query: variable.state.value, }, @@ -346,7 +350,11 @@ export function sceneVariablesSetToSchemaV2Variables( kind: 'IntervalVariable', spec: { ...commonProperties, - current: currentVariableOption, + current: { + ...currentVariableOption, + // Interval variable doesn't use text state + text: variable.state.value, + }, query: intervals, refresh: VariableRefresh.OnTimeRangeChanged, options: variable.state.intervals.map((interval) => ({ @@ -410,7 +418,7 @@ export function sceneVariablesSetToSchemaV2Variables( }; variables.push(adhocVariable); } else { - throw new Error('Unsupported variable type'); + throw new Error('Unsupported variable type: ' + variable.state.type); } } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts new file mode 100644 index 00000000000..2d466858c3d --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -0,0 +1,285 @@ +import { VariableRefresh } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + behaviors, + ConstantVariable, + CustomVariable, + DataSourceVariable, + GroupByVariable, + IntervalVariable, + QueryVariable, + SceneGridLayout, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneVariableSet, + TextBoxVariable, + VizPanel, +} from '@grafana/scenes'; +import { + DashboardCursorSync as DashboardCursorSyncV1, + VariableHide as VariableHideV1, + VariableSort as VariableSortV1, +} from '@grafana/schema/dist/esm/index.gen'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; + +function setupDashboardScene(state: DashboardSceneState): DashboardScene { + return new DashboardScene(state); +} + +describe('transformSceneToSaveModelSchemaV2', () => { + let dashboardScene: DashboardScene; + let prevFeatureToggleValue: boolean; + + beforeAll(() => { + prevFeatureToggleValue = !!config.featureToggles.groupByVariable; + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = prevFeatureToggleValue; + }); + + beforeEach(() => { + // The intention is to have a complete dashboard scene + // with all the possible properties set + dashboardScene = setupDashboardScene({ + title: 'Test Dashboard', + description: 'Test Description', + preload: true, + tags: ['tag1', 'tag2'], + uid: 'test-uid', + version: 1, + $timeRange: new SceneTimeRange({ + timeZone: 'UTC', + from: 'now-1h', + to: 'now', + weekStart: 'monday', + fiscalYearStartMonth: 1, + UNSAFE_nowDelay: '1m', + refreshOnActivate: { + afterMs: 10, + percent: 0.1, + }, + }), + controls: new DashboardControls({ + refreshPicker: new SceneRefreshPicker({ + refresh: '5s', + intervals: ['5s', '10s', '30s'], + autoEnabled: true, + autoMinInterval: '5s', + autoValue: '5s', + isOnCanvas: true, + primary: true, + withText: true, + minRefreshInterval: '5s', + }), + timePicker: new SceneTimePicker({ + isOnCanvas: true, + hidePicker: true, + }), + }), + links: [ + { + title: 'Test Link', + url: 'http://test.com', + asDropdown: false, + icon: '', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + tooltip: '', + type: 'link', + }, + ], + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: false, + children: [ + new DashboardGridItem({ + body: new VizPanel({ + key: 'test-panel-uid', + pluginId: 'timeseries', + title: 'Test Panel', + description: 'Test Description', + hoverHeader: true, + hoverHeaderOffset: 10, + fieldConfig: { defaults: {}, overrides: [] }, + displayMode: 'transparent', + pluginVersion: '7.0.0', + $timeRange: new SceneTimeRange({ + timeZone: 'UTC', + from: 'now-3h', + to: 'now', + }), + }), + // Props related to repeatable panels + // repeatedPanels?: VizPanel[], + // variableName?: string, + // itemHeight?: number, + // repeatDirection?: RepeatDirection, + // maxPerRow?: number, + }), + ], + }), + }), + meta: {}, + $behaviors: [ + new behaviors.CursorSync({ + sync: DashboardCursorSyncV1.Crosshair, + }), + new behaviors.LiveNowTimer({ + enabled: true, + }), + ], + $variables: new SceneVariableSet({ + // Test each of the variables + variables: [ + new QueryVariable({ + name: 'queryVar', + label: 'Query Variable', + description: 'A query variable', + skipUrlSync: false, + hide: VariableHideV1.hideLabel, + value: 'value1', + text: 'text1', + query: 'query1', + definition: 'definition1', + datasource: { uid: 'datasource1', type: 'prometheus' }, + sort: VariableSortV1.alphabeticalDesc, + refresh: VariableRefresh.onDashboardLoad, + regex: 'regex1', + allValue: '*', + includeAll: true, + isMulti: true, + }), + new CustomVariable({ + name: 'customVar', + label: 'Custom Variable', + description: 'A custom variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'option1', + text: 'option1', + query: 'option1, option2', + options: [ + { label: 'option1', value: 'option1' }, + { label: 'option2', value: 'option2' }, + ], + isMulti: true, + allValue: 'All', + includeAll: true, + }), + new DataSourceVariable({ + name: 'datasourceVar', + label: 'Datasource Variable', + description: 'A datasource variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value1', + text: 'text1', + regex: 'regex1', + pluginId: 'datasource1', + defaultOptionEnabled: true, + }), + new ConstantVariable({ + name: 'constantVar', + label: 'Constant Variable', + description: 'A constant variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value4', + }), + new IntervalVariable({ + name: 'intervalVar', + label: 'Interval Variable', + description: 'An interval variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: '1m', + intervals: ['1m', '5m', '10m'], + autoEnabled: false, + autoMinInterval: '1m', + autoStepCount: 10, + }), + new TextBoxVariable({ + name: 'textVar', + label: 'Text Variable', + description: 'A text variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value6', + }), + new GroupByVariable({ + name: 'groupByVar', + label: 'Group By Variable', + description: 'A group by variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value7', + text: 'text7', + datasource: { uid: 'datasource2', type: 'prometheus' }, + defaultOptions: [ + { text: 'option1', value: 'option1' }, + { text: 'option2', value: 'option2' }, + ], + isMulti: false, + includeAll: false, + }), + new AdHocFiltersVariable({ + name: 'adhocVar', + label: 'Adhoc Variable', + description: 'An adhoc variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + datasource: { uid: 'datasource3', type: 'prometheus' }, + baseFilters: [ + { + key: 'key1', + operator: '=', + value: 'value1', + condition: 'AND', + }, + { + key: 'key2', + operator: '=', + value: 'value2', + condition: 'OR', + }, + ], + filters: [ + { + key: 'key3', + operator: '=', + value: 'value3', + condition: 'AND', + }, + ], + defaultKeys: [ + { + text: 'defaultKey1', + value: 'defaultKey1', + group: 'defaultGroup1', + expandable: true, + }, + ], + }), + ], + }), + }); + }); + + it('should transform scene to save model schema v2', () => { + const result = transformSceneToSaveModelSchemaV2(dashboardScene); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 2ef5ee34285..ec8ef3d1940 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -1,5 +1,4 @@ import { behaviors, SceneDataQuery, SceneDataTransformer, SceneVariableSet, VizPanel } from '@grafana/scenes'; - import { DashboardV2Spec, defaultDashboardV2Spec, @@ -24,7 +23,9 @@ import { ConstantVariableKind, GroupByVariableKind, AdhocVariableKind, -} from '../../../../../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen'; +} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen'; +import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; + import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; @@ -59,6 +60,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps editable: oldDash.editable, links: transformDashboardLinksToEnums(oldDash.links), tags: oldDash.tags, + schemaVersion: DASHBOARD_SCHEMA_VERSION, // EOF dashboard settings // time settings From 0cd19d76ce2b91dd199a2986309c64bf17f2ebf9 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 20 Nov 2024 13:07:36 +0000 Subject: [PATCH 58/60] Chore: Enhance debug theme with more hover colors (#96753) enhance debug theme with more hover colors --- packages/grafana-data/src/themes/registry.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index c6066d22075..f05ea638735 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -85,12 +85,30 @@ function createDebug(): GrafanaTheme2 { border: '#ff000088', text: '#cccc00', contrastText: '#ffff00', + shade: '#9900dd', }, secondary: { ...baseDarkColors.secondary, border: '#ff000088', text: '#cccc00', contrastText: '#ffff00', + shade: '#9900dd', + }, + info: { + ...baseDarkColors.info, + shade: '#9900dd', + }, + warning: { + ...baseDarkColors.warning, + shade: '#9900dd', + }, + success: { + ...baseDarkColors.success, + shade: '#9900dd', + }, + error: { + ...baseDarkColors.error, + shade: '#9900dd', }, action: { hover: '#9900dd', From b0fe898fa1db3dd6524517741146c9c220b0023f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 20 Nov 2024 14:09:56 +0100 Subject: [PATCH 59/60] Dashboard: Panel edit and support for different layout items (#96203) * Dashboard: Panel edit and support for more layout items * It's working * Fix discard issue * remove unused file * Update * Editing for responsive grid items now work * Update * Update * Review fix --- .../panel-edit/PanelEditor.test.ts | 2 + .../panel-edit/PanelEditor.tsx | 38 +++-- .../panel-edit/PanelOptions.tsx | 10 +- .../panel-edit/getPanelFrameOptions.tsx | 83 +---------- .../layout-default/DashboardGridItem.tsx | 23 ++- .../DashboardGridItemEditor.tsx | 95 ++++++++++++ .../DefaultGridLayoutManager.tsx | 4 +- .../ResponsiveGridItem.tsx | 27 ++-- .../features/dashboard-scene/scene/types.ts | 16 +- .../PanelEditor/OptionsPaneItemDescriptor.tsx | 139 ++++++++++-------- 10 files changed, 251 insertions(+), 186 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layout-default/DashboardGridItemEditor.tsx diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 65c8c623d76..fa309c8bd21 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -267,6 +267,8 @@ describe('PanelEditor', () => { // Just adding an extra stateless behavior to verify unlinking does not remvoe it const otherBehavior = jest.fn(); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); + new DashboardGridItem({ body: panel }); + const editScene = buildPanelEditScene(panel); editScene.onConfirmUnlinkLibraryPanel(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index b0073be67ed..c8de84d8d46 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, DashboardGridItemState } from '../scene/layout-default/DashboardGridItem'; +import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateSceneObjectAndParentTree, @@ -54,14 +54,22 @@ export interface PanelEditorState extends SceneObjectState { export class PanelEditor extends SceneObjectBase { static Component = PanelEditorRenderer; - private _originalLayoutElementState!: DashboardGridItemState; - private _layoutElement!: DashboardGridItem; + private _layoutItemState?: SceneObjectState; + private _layoutItem: DashboardLayoutItem; private _originalSaveModel!: Panel; private _changesHaveBeenMade = false; public constructor(state: PanelEditorState) { super(state); + const panel = this.state.panelRef.resolve(); + const layoutItem = panel.parent; + if (!layoutItem || !isDashboardLayoutItem(layoutItem)) { + throw new Error('Panel must have a parent of type DashboardLayoutItem'); + } + + this._layoutItem = layoutItem; + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } @@ -69,14 +77,12 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); const deactivateParents = activateSceneObjectAndParentTree(panel); - const layoutElement = panel.parent; this.waitForPlugin(); return () => { - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade); - } + this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade); + if (deactivateParents) { deactivateParents(); } @@ -102,11 +108,7 @@ export class PanelEditor extends SceneObjectBase { const panel = panelRef.resolve(); this._originalSaveModel = vizPanelToPanel(panel); - - if (panel.parent instanceof DashboardGridItem) { - this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); - this._layoutElement = panel.parent; - } + this._layoutItemState = sceneUtils.cloneSceneObjectState(this._layoutItem.state); } /** @@ -134,9 +136,8 @@ export class PanelEditor extends SceneObjectBase { } }; - this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); - // Repeat options live on the layout element (DashboardGridItem) - this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Subscribe to state changes on the parent (layout item) so we do not miss state changes on the layout item + this._subs.add(this._layoutItem.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); } public getPanel(): VizPanel { @@ -145,15 +146,12 @@ export class PanelEditor extends SceneObjectBase { private gotPanelPlugin(plugin: PanelPlugin) { const panel = this.getPanel(); - const layoutElement = panel.parent; // First time initialization if (this.state.isInitializing) { this.setOriginalState(this.state.panelRef); - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingStarted(); - } + this._layoutItem.editingStarted?.(); this._setupChangeDetection(); this._updateDataPane(plugin); @@ -255,7 +253,7 @@ export class PanelEditor extends SceneObjectBase { getDashboardSceneFor(this).removePanel(panel); } else { // Revert any layout element changes - this._layoutElement.setState(this._originalLayoutElementState!); + this._layoutItem!.setState(this._layoutItemState!); } locationService.partial({ editPanel: null }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 3467f8c8f5e..fd373104caf 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,7 +13,7 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { getPanelFrameCategory2 } from './getPanelFrameOptions'; +import { getPanelFrameOptions } from './getPanelFrameOptions'; interface Props { panel: VizPanel; @@ -25,13 +25,7 @@ interface Props { export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); - const layoutElement = panel.parent!; - const layoutElementState = layoutElement.useState(); - - const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(panel, layoutElementState), - [panel, layoutElementState] - ); + const panelFrameOptions = useMemo(() => getPanelFrameOptions(panel), [panel]); const visualizationOptions = useMemo(() => { const plugin = panel.getPlugin(); diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 629430b1ecc..a75b82a2531 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,26 +1,21 @@ -import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { SceneObjectState, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; -import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { VizPanelLinks } from '../scene/PanelLinks'; import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -export function getPanelFrameCategory2( - panel: VizPanel, - layoutElementState: SceneObjectState -): OptionsPaneCategoryDescriptor { +export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', id: 'Panel options', @@ -30,7 +25,7 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); - const layoutElement = panel.parent; + const layoutElement = panel.parent!; descriptor .addItem( @@ -97,72 +92,8 @@ export function getPanelFrameCategory2( ) ); - if (layoutElement instanceof DashboardGridItem) { - const gridItem = layoutElement; - - const category = new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, - }); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - gridItem.setRepeatByVariable(value)} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => Boolean(gridItem.state.variableName), - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - gridItem.setState({ repeatDirection: value })} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); +} + +function RepeatByOption({ gridItem }: OptionComponentProps) { + const { variableName } = gridItem.useState(); + + return ( + gridItem.setRepeatByVariable(value)} + /> + ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 8d7f0f7ff86..3a2d73cc777 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -370,8 +370,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: children, - isDraggable: false, - isResizable: false, + isDraggable: true, + isResizable: true, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index 18cb5aaf415..d1671c3cb23 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -1,13 +1,16 @@ import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes'; import { Switch } from '@grafana/ui'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { DashboardLayoutItem } from '../types'; + export interface ResponsiveGridItemState extends SceneObjectState { body: VizPanel; hideWhenNoData?: boolean; } -export class ResponsiveGridItem extends SceneObjectBase { +export class ResponsiveGridItem extends SceneObjectBase implements DashboardLayoutItem { public constructor(state: ResponsiveGridItemState) { super(state); this.addActivationHandler(() => this._activationHandler()); @@ -17,8 +20,6 @@ export class ResponsiveGridItem extends SceneObjectBase if (!this.state.hideWhenNoData) { return; } - - // TODO add hide when no data logic (in a behavior probably) } public toggleHideWhenNoData() { @@ -28,20 +29,28 @@ export class ResponsiveGridItem extends SceneObjectBase /** * DashboardLayoutElement interface */ - public isDashboardLayoutElement: true = true; + public isDashboardLayoutItem: true = true; - public getOptions?(): OptionsPaneItemDescriptor[] { + public getOptions?(): OptionsPaneCategoryDescriptor { const model = this; - return [ + const category = new OptionsPaneCategoryDescriptor({ + title: 'Layout options', + id: 'layout-options', + isOpenDefault: false, + }); + + category.addItem( new OptionsPaneItemDescriptor({ title: 'Hide when no data', render: function renderTransparent() { - const { hideWhenNoData } = model.state; + const { hideWhenNoData } = model.useState(); return model.toggleHideWhenNoData()} />; }, - }), - ]; + }) + ); + + return category; } public setBody(body: SceneObject): void { diff --git a/public/app/features/dashboard-scene/scene/types.ts b/public/app/features/dashboard-scene/scene/types.ts index e65a06e533a..aa80c617045 100644 --- a/public/app/features/dashboard-scene/scene/types.ts +++ b/public/app/features/dashboard-scene/scene/types.ts @@ -1,6 +1,6 @@ import { BusEventWithPayload, RegistryItem } from '@grafana/data'; import { SceneObject, VizPanel } from '@grafana/scenes'; -import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; /** * A scene object that usually wraps an underlying layout @@ -94,17 +94,21 @@ export function isLayoutParent(obj: SceneObject): obj is LayoutParent { */ export interface DashboardLayoutItem extends SceneObject { /** - * Marks this object as a layout element + * Marks this object as a layout item */ isDashboardLayoutItem: true; /** - * Return layout elements options (like repeat, repeat direction, etc for the default DashboardGridItem) + * Return layout item options (like repeat, repeat direction, etc for the default DashboardGridItem) */ - getOptions?(): OptionsPaneItemDescriptor[]; + getOptions?(): OptionsPaneCategoryDescriptor; /** - * Only implemented by elements that wrap VizPanels + * When going into panel edit + **/ + editingStarted?(): void; + /** + * When coming out of panel edit */ - getVizPanel?(): VizPanel; + editingCompleted?(withChanges: boolean): void; } export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem { diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx index 54d84347cd0..645dc5cad16 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx @@ -11,7 +11,7 @@ import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { OptionsPaneItemOverrides } from './OptionsPaneItemOverrides'; import { OptionPaneItemOverrideInfo } from './types'; -export interface OptionsPaneItemProps { +export interface OptionsPaneItemInfo { title: string; value?: any; description?: string; @@ -19,6 +19,8 @@ export interface OptionsPaneItemProps { render: () => React.ReactElement; skipField?: boolean; showIf?: () => boolean; + /** Hook for controlling visibility */ + useShowIf?: () => boolean; overrides?: OptionPaneItemOverrideInfo[]; addon?: ReactNode; } @@ -29,74 +31,87 @@ export interface OptionsPaneItemProps { export class OptionsPaneItemDescriptor { parent!: OptionsPaneCategoryDescriptor; - constructor(public props: OptionsPaneItemProps) {} - - getLabel(searchQuery?: string): ReactNode { - const { title, description, overrides, addon } = this.props; - - if (!searchQuery) { - // Do not render label for categories with only one child - if (this.parent.props.title === title && !overrides?.length) { - return null; - } - - return ; - } - - const categories: React.ReactNode[] = []; - - if (this.parent.parent) { - categories.push(this.highlightWord(this.parent.parent.props.title, searchQuery)); - } - - if (this.parent.props.title !== title) { - categories.push(this.highlightWord(this.parent.props.title, searchQuery)); - } - - return ( - - ); - } - - highlightWord(word: string, query: string) { - return ( - - ); - } - - renderOverrides() { - const { overrides } = this.props; - if (!overrides || overrides.length === 0) { - return; - } - } + constructor(public props: OptionsPaneItemInfo) {} render(searchQuery?: string) { - const { title, description, render, showIf, skipField } = this.props; - const key = `${this.parent.props.id} ${title}`; + return ; + } - if (showIf && !showIf()) { + useShowIf() { + if (this.props.useShowIf) { + return this.props.useShowIf(); + } + + if (this.props.showIf) { + return this.props.showIf(); + } + + return true; + } +} + +interface OptionsPaneItemProps { + itemDescriptor: OptionsPaneItemDescriptor; + searchQuery?: string; +} + +function OptionsPaneItem({ itemDescriptor, searchQuery }: OptionsPaneItemProps) { + const { title, description, render, skipField } = itemDescriptor.props; + const key = `${itemDescriptor.parent.props.id} ${title}`; + const showIf = itemDescriptor.useShowIf(); + + if (!showIf) { + return null; + } + + if (skipField) { + return render(); + } + + return ( + + {render()} + + ); +} + +function renderOptionLabel(itemDescriptor: OptionsPaneItemDescriptor, searchQuery?: string): ReactNode { + const { title, description, overrides, addon } = itemDescriptor.props; + + if (!searchQuery) { + // Do not render label for categories with only one child + if (itemDescriptor.parent.props.title === title && !overrides?.length) { return null; } - if (skipField) { - return render(); - } - - return ( - - {render()} - - ); + return ; } + + const categories: React.ReactNode[] = []; + + if (itemDescriptor.parent.parent) { + categories.push(highlightWord(itemDescriptor.parent.parent.props.title, searchQuery)); + } + + if (itemDescriptor.parent.props.title !== title) { + categories.push(highlightWord(itemDescriptor.parent.props.title, searchQuery)); + } + + return ( + + ); +} + +function highlightWord(word: string, query: string) { + return ; } interface OptionPanelLabelProps { From e0b93f055fd2bec2db8eae8d428ce08fe61627ad Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 20 Nov 2024 16:43:25 +0300 Subject: [PATCH 60/60] Swagger: show user icon (#96736) --- .betterer.results | 3 ++- public/swagger/SwaggerPage.tsx | 31 +++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.betterer.results b/.betterer.results index 152968f9869..e1996888277 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5626,7 +5626,8 @@ exports[`better eslint`] = { ], "public/swagger/SwaggerPage.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/swagger/index.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/swagger/SwaggerPage.tsx b/public/swagger/SwaggerPage.tsx index f32033cc206..e74f411e867 100644 --- a/public/swagger/SwaggerPage.tsx +++ b/public/swagger/SwaggerPage.tsx @@ -4,7 +4,7 @@ import { useAsync } from 'react-use'; import SwaggerUI from 'swagger-ui-react'; import { createTheme, monacoLanguageRegistry, SelectableValue } from '@grafana/data'; -import { Stack, Select } from '@grafana/ui'; +import { Stack, Select, UserIcon, UserView, Button } from '@grafana/ui'; import { setMonacoEnv } from 'app/core/monacoEnv'; import { ThemeProvider } from 'app/core/utils/ConfigProvider'; @@ -49,16 +49,34 @@ export const Page = () => { return urls; }); + const [userView, setUserView] = useState(); + const namespace = useAsync(async () => { const response = await fetch('api/frontend/settings'); if (!response.ok) { console.warn('No settings found'); - return ''; + return 'default'; } const val = await response.json(); return val.namespace; }); + useAsync(async () => { + const response = await fetch('api/user'); + if (!response.ok) { + console.warn('No user found, show login button'); + return; + } + const val = await response.json(); + setUserView({ + user: { + name: val.email || val.login, + avatarUrl: val.avatarUrl, + }, + lastActiveAt: new Date(), + }); + }); + return (
@@ -83,6 +101,15 @@ export const Page = () => { value={url} isLoading={urls.loading} /> +
+ {userView ? ( + + ) : ( + + + + )} +