Compare commits

...
Author SHA1 Message Date
Andreas Christou 2bd1aec8dc Merge branch 'main' into andreas/graphite-alias-fix 2026-01-14 17:16:32 +00:00
Kristina Demeshchik 87f5d5e741 Dashboard: Hide export options in collapsible row (#116155)
* Introduce export options

* Reset keys

* Introduce a new key

* Generate new keys

* Rename the label

* re-generate key

* Fix the spacing

* Remove debuggers

* Add subtitle

* refactor component

* update labels

* faield tests

* Update tooltip

* Linting issue
2026-01-14 12:12:33 -05:00
Andrew Hackmann 5e68b07cac Elasticsearch: Make code editor look more like prometheus (#115461)
* Make code editor look more prometheus

* add warning when switching builders

* address adam's feedback

* yarn
2026-01-14 09:50:35 -07:00
Andreas Christou eaf354088f Ensure we're checking the target correctly 2026-01-14 16:45:39 +00:00
Adela Almasan 99acd3766d Suggestions: Update empty state (#116172) 2026-01-14 10:37:42 -06:00
Andreas Christou e99d7da667 Use target as name for aliased queries 2026-01-13 17:53:02 +00:00
15 changed files with 618 additions and 154 deletions
+2 -2
View File
@@ -39,7 +39,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
_, span := tracing.DefaultTracer().Start(ctx, "graphite healthcheck") _, span := tracing.DefaultTracer().Start(ctx, "graphite healthcheck")
defer span.End() defer span.End()
graphiteReq, formData, _, err := s.createGraphiteRequest(ctx, healthCheckQuery, dsInfo) graphiteReq, formData, _, _, err := s.createGraphiteRequest(ctx, healthCheckQuery, dsInfo)
if err != nil { if err != nil {
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
@@ -81,7 +81,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
} }
}() }()
_, err = s.toDataFrames(res, healthCheckQuery.RefID, false) _, err = s.toDataFrames(res, healthCheckQuery.RefID, false, targetStr)
if err != nil { if err != nil {
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
+23 -15
View File
@@ -24,15 +24,16 @@ import (
func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo) (*backend.QueryDataResponse, error) { func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo) (*backend.QueryDataResponse, error) {
emptyQueries := []string{} emptyQueries := []string{}
graphiteQueries := map[string]struct { graphiteQueries := map[string]struct {
req *http.Request req *http.Request
formData url.Values formData url.Values
rawTarget string
}{} }{}
// FromAlert header is defined in pkg/services/ngalert/models/constants.go // FromAlert header is defined in pkg/services/ngalert/models/constants.go
fromAlert := req.Headers["FromAlert"] == "true" fromAlert := req.Headers["FromAlert"] == "true"
result := backend.NewQueryDataResponse() result := backend.NewQueryDataResponse()
for _, query := range req.Queries { for _, query := range req.Queries {
graphiteReq, formData, emptyQuery, err := s.createGraphiteRequest(ctx, query, dsInfo) graphiteReq, formData, emptyQuery, target, err := s.createGraphiteRequest(ctx, query, dsInfo)
if err != nil { if err != nil {
result.Responses[query.RefID] = backend.ErrorResponseWithErrorSource(err) result.Responses[query.RefID] = backend.ErrorResponseWithErrorSource(err)
return result, nil return result, nil
@@ -44,11 +45,13 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d
} }
graphiteQueries[query.RefID] = struct { graphiteQueries[query.RefID] = struct {
req *http.Request req *http.Request
formData url.Values formData url.Values
rawTarget string
}{ }{
req: graphiteReq, req: graphiteReq,
formData: formData, formData: formData,
rawTarget: target,
} }
} }
@@ -99,7 +102,7 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d
} }
}() }()
queryFrames, err := s.toDataFrames(res, refId, fromAlert) queryFrames, err := s.toDataFrames(res, refId, fromAlert, graphiteReq.rawTarget)
if err != nil { if err != nil {
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
@@ -147,7 +150,7 @@ func (s *Service) processQuery(query backend.DataQuery) (string, *GraphiteQuery,
return target, nil, queryJSON.IsMetricTank, nil return target, nil, queryJSON.IsMetricTank, nil
} }
func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, dsInfo *datasourceInfo) (*http.Request, url.Values, *GraphiteQuery, error) { func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, dsInfo *datasourceInfo) (*http.Request, url.Values, *GraphiteQuery, string, error) {
/* /*
graphite doc about from and until, with sdk we are getting absolute instead of relative time graphite doc about from and until, with sdk we are getting absolute instead of relative time
https://graphite-api.readthedocs.io/en/latest/api.html#from-until https://graphite-api.readthedocs.io/en/latest/api.html#from-until
@@ -163,12 +166,12 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
target, emptyQuery, isMetricTank, err := s.processQuery(query) target, emptyQuery, isMetricTank, err := s.processQuery(query)
if err != nil { if err != nil {
return nil, formData, nil, err return nil, formData, nil, "", err
} }
if emptyQuery != nil { if emptyQuery != nil {
s.logger.Debug("Graphite", "empty query target", emptyQuery) s.logger.Debug("Graphite", "empty query target", emptyQuery)
return nil, formData, emptyQuery, nil return nil, formData, emptyQuery, "", nil
} }
formData["target"] = []string{target} formData["target"] = []string{target}
@@ -188,20 +191,23 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
QueryParams: params, QueryParams: params,
}) })
if err != nil { if err != nil {
return nil, formData, nil, err return nil, formData, nil, "", err
} }
return graphiteReq, formData, emptyQuery, nil return graphiteReq, formData, emptyQuery, target, nil
} }
func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert bool) (frames data.Frames, error error) { func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert bool, rawTarget string) (frames data.Frames, error error) {
responseData, err := s.parseResponse(response) responseData, err := s.parseResponse(response)
if err != nil { if err != nil {
return nil, err return nil, err
} }
aliasRegex := regexp.MustCompile(`(alias\(|aliasByMetric|aliasByNode|aliasByTags|aliasQuery|aliasSub)`)
frames = data.Frames{} frames = data.Frames{}
for _, series := range responseData { for _, series := range responseData {
aliasMatch := aliasRegex.MatchString(rawTarget)
timeVector := make([]time.Time, 0, len(series.DataPoints)) timeVector := make([]time.Time, 0, len(series.DataPoints))
values := make([]*float64, 0, len(series.DataPoints)) values := make([]*float64, 0, len(series.DataPoints))
@@ -217,7 +223,9 @@ func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert
tags := make(map[string]string) tags := make(map[string]string)
for name, value := range series.Tags { for name, value := range series.Tags {
if name == "name" { if name == "name" {
if fromAlert { // Queries with aliases should use the target as the name
// to ensure multi-dimensional queries are distinguishable from each other
if fromAlert || aliasMatch {
value = series.Target value = series.Target
} }
} }
+124 -11
View File
@@ -182,7 +182,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame} expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false) dataFrames, err := service.toDataFrames(httpResponse, refId, false, "target")
require.NoError(t, err) require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) { if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -196,7 +196,7 @@ func TestConvertResponses(t *testing.T) {
body := ` body := `
[ [
{ {
"target": "aliasedTarget(target)", "target": "alias(target)",
"tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 }, "tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 },
"datapoints": [[50, 1], [null, 2], [100, 3]] "datapoints": [[50, 1], [null, 2], [100, 3]]
} }
@@ -211,13 +211,13 @@ func TestConvertResponses(t *testing.T) {
"barTag": "barValue", "barTag": "barValue",
"int": "100", "int": "100",
"float": "3.14", "float": "3.14",
"name": "target", "name": "alias(target)",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}), }, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "alias(target)"}),
).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})
expectedFrames := data.Frames{expectedFrame} expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false) dataFrames, err := service.toDataFrames(httpResponse, refId, false, "alias(target)")
require.NoError(t, err) require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) { if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -240,7 +240,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{} expectedFrames := data.Frames{}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false) dataFrames, err := service.toDataFrames(httpResponse, refId, false, "")
require.NoError(t, err) require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) { if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -281,7 +281,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame} expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false) dataFrames, err := service.toDataFrames(httpResponse, refId, false, "target")
require.NoError(t, err) require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) { if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -295,7 +295,7 @@ func TestConvertResponses(t *testing.T) {
body := ` body := `
[ [
{ {
"target": "aliasedTarget(target)", "target": "alias(target)",
"tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 }, "tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 },
"datapoints": [[50, 1], [null, 2], [100, 3]] "datapoints": [[50, 1], [null, 2], [100, 3]]
} }
@@ -310,13 +310,13 @@ func TestConvertResponses(t *testing.T) {
"barTag": "barValue", "barTag": "barValue",
"int": "100", "int": "100",
"float": "3.14", "float": "3.14",
"name": "aliasedTarget(target)", "name": "alias(target)",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}), }, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "alias(target)"}),
).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})
expectedFrames := data.Frames{expectedFrame} expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, true) dataFrames, err := service.toDataFrames(httpResponse, refId, true, "alias(target)")
require.NoError(t, err) require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) { if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -738,6 +738,119 @@ Error: Target not found
} }
} }
func TestAliasMatching(t *testing.T) {
service := &Service{
logger: backend.Logger,
}
testCases := []struct {
name string
target string
tagsName string
fromAlert bool
expectedLabelName string
}{
{
name: "alias() function sets name tag to target",
target: "alias(stats.counters.web.hits, 'Web Hits')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "alias(stats.counters.web.hits, 'Web Hits')",
},
{
name: "aliasByNode() function sets name tag to target",
target: "aliasByNode(stats.counters.web.hits, 2)",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByNode(stats.counters.web.hits, 2)",
},
{
name: "aliasByMetric() function sets name tag to target",
target: "aliasByMetric(stats.counters.web.hits)",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByMetric(stats.counters.web.hits)",
},
{
name: "aliasByTags() function sets name tag to target",
target: "aliasByTags(stats.counters.web.hits, 'host')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByTags(stats.counters.web.hits, 'host')",
},
{
name: "aliasSub() function sets name tag to target",
target: "aliasSub(stats.counters.web.hits, 'stats', 'metrics')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasSub(stats.counters.web.hits, 'stats', 'metrics')",
},
{
name: "aliasQuery() function sets name tag to target",
target: "aliasQuery(stats.counters.web.hits, 'SELECT name FROM hosts')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasQuery(stats.counters.web.hits, 'SELECT name FROM hosts')",
},
{
name: "no alias function keeps original name tag",
target: "stats.counters.web.hits",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "stats.counters.web.hits",
},
{
name: "fromAlert overrides name tag even without alias",
target: "stats.counters.web.hits",
tagsName: "original.name",
fromAlert: true,
expectedLabelName: "stats.counters.web.hits",
},
{
name: "nested alias function matches",
target: "sumSeries(alias(stats.counters.*.hits, 'Hits'))",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "sumSeries(alias(stats.counters.*.hits, 'Hits'))",
},
{
name: "alias in metric path should not match",
target: "stats.alias.web.hits",
tagsName: "stats.alias.web.hits",
fromAlert: false,
expectedLabelName: "stats.alias.web.hits",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := fmt.Sprintf(`[
{
"target": %q,
"tags": { "name": %q, "host": "server1" },
"datapoints": [[100, 1609459200]]
}
]`, tc.target, tc.tagsName)
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, "A", tc.fromAlert, tc.target)
require.NoError(t, err)
require.Len(t, dataFrames, 1)
frame := dataFrames[0]
require.GreaterOrEqual(t, len(frame.Fields), 2)
valueField := frame.Fields[1]
require.NotNil(t, valueField.Labels)
actualName, ok := valueField.Labels["name"]
require.True(t, ok, "name label should exist")
assert.Equal(t, tc.expectedLabelName, actualName, "name label should match expected value")
})
}
}
func TestParseGraphiteError(t *testing.T) { func TestParseGraphiteError(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -25,6 +25,10 @@ export class ExportAsCode extends ShareExportTab {
public getTabLabel(): string { public getTabLabel(): string {
return t('export.json.title', 'Export dashboard'); return t('export.json.title', 'Export dashboard');
} }
public getSubtitle(): string | undefined {
return t('export.json.info-text', 'Copy or download a file containing the definition of your dashboard');
}
} }
function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) { function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
@@ -53,12 +57,6 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
return ( return (
<div data-testid={selector.container} className={styles.container}> <div data-testid={selector.container} className={styles.container}>
<p>
<Trans i18nKey="export.json.info-text">
Copy or download a file containing the definition of your dashboard
</Trans>
</p>
{config.featureToggles.kubernetesDashboards ? ( {config.featureToggles.kubernetesDashboards ? (
<ResourceExport <ResourceExport
dashboardJson={dashboardJson} dashboardJson={dashboardJson}
@@ -0,0 +1,189 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncState } from 'react-use/lib/useAsync';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { ExportMode, ResourceExport } from './ResourceExport';
type DashboardJsonState = AsyncState<{
json: Dashboard | DashboardV2Spec | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}>;
const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
const createDefaultProps = (overrides?: Partial<Parameters<typeof ResourceExport>[0]>) => {
const defaultProps: Parameters<typeof ResourceExport>[0] = {
dashboardJson: {
loading: false,
value: {
json: { title: 'Test Dashboard' } as Dashboard,
hasLibraryPanels: false,
initialSaveModelVersion: 'v1',
},
} as DashboardJsonState,
isSharingExternally: false,
exportMode: ExportMode.Classic,
isViewingYAML: false,
onExportModeChange: jest.fn(),
onShareExternallyChange: jest.fn(),
onViewYAML: jest.fn(),
};
return { ...defaultProps, ...overrides };
};
const createV2DashboardJson = (hasLibraryPanels = false): DashboardJsonState => ({
loading: false,
value: {
json: {
title: 'Test V2 Dashboard',
spec: {
elements: {},
},
} as unknown as DashboardV2Spec,
hasLibraryPanels,
initialSaveModelVersion: 'v2',
},
});
const expandOptions = async () => {
const button = screen.getByRole('button', { expanded: false });
await userEvent.click(button);
};
describe('ResourceExport', () => {
describe('export mode options for v1 dashboard', () => {
it('should show three export mode options in correct order: Classic, V1 Resource, V2 Resource', async () => {
render(<ResourceExport {...createDefaultProps()} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const labels = within(radioGroup)
.getAllByRole('radio')
.map((radio) => radio.parentElement?.textContent?.trim());
expect(labels).toHaveLength(3);
expect(labels).toEqual(['Classic', 'V1 Resource', 'V2 Resource']);
});
it('should have first option selected by default when exportMode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
expect(radios[0]).toBeChecked();
});
it('should call onExportModeChange when export mode is changed', async () => {
const onExportModeChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ onExportModeChange })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
await userEvent.click(radios[1]); // V1 Resource
expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource);
});
});
describe('export mode options for v2 dashboard', () => {
it('should not show export mode options', async () => {
render(<ResourceExport {...createDefaultProps({ dashboardJson: createV2DashboardJson() })} />);
await expandOptions();
expect(screen.queryByRole('radiogroup', { name: /model/i })).not.toBeInTheDocument();
});
});
describe('format options', () => {
it('should not show format options when export mode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument();
});
it.each([ExportMode.V1Resource, ExportMode.V2Resource])(
'should show format options when export mode is %s',
async (exportMode) => {
render(<ResourceExport {...createDefaultProps({ exportMode })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: /format/i })).toBeInTheDocument();
}
);
it('should have first format option selected when isViewingYAML is false', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: false })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[0]).toBeChecked(); // JSON
});
it('should have second format option selected when isViewingYAML is true', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: true })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[1]).toBeChecked(); // YAML
});
it('should call onViewYAML when format is changed', async () => {
const onViewYAML = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, onViewYAML })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
await userEvent.click(formatRadios[1]); // YAML
expect(onViewYAML).toHaveBeenCalled();
});
});
describe('share externally switch', () => {
it('should show share externally switch for Classic mode', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should show share externally switch for V2Resource mode with V2 dashboard', () => {
render(
<ResourceExport
{...createDefaultProps({
dashboardJson: createV2DashboardJson(),
exportMode: ExportMode.V2Resource,
})}
/>
);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should call onShareExternallyChange when switch is toggled', async () => {
const onShareExternallyChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, onShareExternallyChange })} />);
const switchElement = screen.getByTestId(selector.exportExternallyToggle);
await userEvent.click(switchElement);
expect(onShareExternallyChange).toHaveBeenCalled();
});
it('should reflect isSharingExternally value in switch', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, isSharingExternally: true })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked();
});
});
});
@@ -4,7 +4,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema'; import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui'; import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { DashboardJson } from 'app/features/manage-dashboards/types'; import { DashboardJson } from 'app/features/manage-dashboards/types';
import { ExportableResource } from '../ShareExportTab'; import { ExportableResource } from '../ShareExportTab';
@@ -48,80 +49,90 @@ export function ResourceExport({
const switchExportLabel = const switchExportLabel =
exportMode === ExportMode.V2Resource exportMode === ExportMode.V2Resource
? t('export.json.export-remove-ds-refs', 'Remove deployment details') ? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance')
: t('share-modal.export.share-externally-label', `Export for sharing externally`); : t('share-modal.export.share-externally-label', 'Export for sharing externally');
const switchExportTooltip = t(
'dashboard-scene.resource-export.share-externally-tooltip',
'Removes all instance-specific metadata and data source references from the resource before export.'
);
const switchExportModeLabel = t('export.json.export-mode', 'Model'); const switchExportModeLabel = t('export.json.export-mode', 'Model');
const switchExportFormatLabel = t('export.json.export-format', 'Format'); const switchExportFormatLabel = t('export.json.export-format', 'Format');
const exportResourceOptions = [
{
label: t('dashboard-scene.resource-export.label.classic', 'Classic'),
value: ExportMode.Classic,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
];
return ( return (
<Stack gap={2} direction="column"> <>
<Stack gap={1} direction="column"> <QueryOperationRow
{initialSaveModelVersion === 'v1' && ( id="Advanced options"
<Stack alignItems="center"> index={0}
<Label>{switchExportModeLabel}</Label> title={t('dashboard-scene.resource-export.label.advanced-options', 'Advanced options')}
<RadioButtonGroup isOpen={false}
options={[ >
{ label: t('dashboard-scene.resource-export.label.classic', 'Classic'), value: ExportMode.Classic }, <Box marginTop={2}>
{ <Stack gap={1} direction="column">
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), {initialSaveModelVersion === 'v1' && (
value: ExportMode.V1Resource, <Stack gap={1} alignItems="center">
}, <Label>{switchExportModeLabel}</Label>
{ <RadioButtonGroup
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), options={exportResourceOptions}
value: ExportMode.V2Resource, value={exportMode}
}, onChange={(value) => onExportModeChange(value)}
]} aria-label={switchExportModeLabel}
value={exportMode} />
onChange={(value) => onExportModeChange(value)} </Stack>
/> )}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
aria-label={switchExportFormatLabel}
/>
</Stack>
)}
</Stack> </Stack>
)} </Box>
{initialSaveModelVersion === 'v2' && ( </QueryOperationRow>
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label> {(isV2Dashboard ||
<RadioButtonGroup exportMode === ExportMode.Classic ||
options={[ (initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
{ <Stack gap={1} alignItems="start">
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'), <Label>
value: ExportMode.V2Resource, <Stack gap={0.5} alignItems="center">
}, <Tooltip content={switchExportTooltip} placement="bottom">
{ <Icon name="info-circle" size="sm" />
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'), </Tooltip>
value: ExportMode.V1Resource, {switchExportLabel}
}, </Stack>
]} </Label>
value={exportMode} <Switch
onChange={(value) => onExportModeChange(value)} label={switchExportLabel}
/> value={isSharingExternally}
</Stack> onChange={onShareExternallyChange}
)} data-testid={selector.exportExternallyToggle}
{exportMode !== ExportMode.Classic && ( />
<Stack gap={1} alignItems="center"> </Stack>
<Label>{switchExportFormatLabel}</Label> )}
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
/>
</Stack>
)}
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch
label={switchExportLabel}
value={isSharingExternally}
onChange={onShareExternallyChange}
data-testid={selector.exportExternallyToggle}
/>
</Stack>
)}
</Stack>
{showV2LibPanelAlert && ( {showV2LibPanelAlert && (
<Alert <Alert
@@ -130,6 +141,7 @@ export function ResourceExport({
'Library panels will be converted to regular panels' 'Library panels will be converted to regular panels'
)} )}
severity="warning" severity="warning"
topSpacing={2}
> >
<Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export"> <Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export">
Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with
@@ -137,6 +149,6 @@ export function ResourceExport({
</Trans> </Trans>
</Alert> </Alert>
)} )}
</Stack> </>
); );
} }
@@ -66,7 +66,12 @@ function ShareDrawerRenderer({ model }: SceneComponentProps<ShareDrawer>) {
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
return ( return (
<Drawer title={activeShare?.getTabLabel()} onClose={model.onDismiss} size="md"> <Drawer
title={activeShare?.getTabLabel()}
subtitle={activeShare?.getSubtitle?.()}
onClose={model.onDismiss}
size="md"
>
<ShareDrawerContext.Provider value={{ dashboard, onDismiss: model.onDismiss }}> <ShareDrawerContext.Provider value={{ dashboard, onDismiss: model.onDismiss }}>
{activeShare && <activeShare.Component model={activeShare} />} {activeShare && <activeShare.Component model={activeShare} />}
</ShareDrawerContext.Provider> </ShareDrawerContext.Provider>
@@ -66,6 +66,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
return t('share-modal.tab-title.export', 'Export'); return t('share-modal.tab-title.export', 'Export');
} }
public getSubtitle(): string | undefined {
return undefined;
}
public onShareExternallyChange = () => { public onShareExternallyChange = () => {
this.setState({ this.setState({
isSharingExternally: !this.state.isSharingExternally, isSharingExternally: !this.state.isSharingExternally,
@@ -15,5 +15,6 @@ export interface SceneShareTab<T extends SceneShareTabState = SceneShareTabState
export interface ShareView extends SceneObject { export interface ShareView extends SceneObject {
getTabLabel(): string; getTabLabel(): string;
getSubtitle?(): string | undefined;
onDismiss?: () => void; onDismiss?: () => void;
} }
@@ -2,8 +2,9 @@ import { render, screen } from '@testing-library/react';
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data'; import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
import { PanelDataErrorViewProps } from '@grafana/runtime'; import { config, PanelDataErrorViewProps } from '@grafana/runtime';
import { usePanelContext } from '@grafana/ui';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { PanelDataErrorView } from './PanelDataErrorView'; import { PanelDataErrorView } from './PanelDataErrorView';
@@ -16,7 +17,24 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
}, },
})); }));
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
usePanelContext: jest.fn(),
}));
const mockUsePanelContext = jest.mocked(usePanelContext);
const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types';
const panelContextRoot = {
app: CoreApp.Dashboard,
eventsScope: 'global',
eventBus: new EventBusSrv(),
};
describe('PanelDataErrorView', () => { describe('PanelDataErrorView', () => {
beforeEach(() => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
});
it('show No data when there is no data', () => { it('show No data when there is no data', () => {
renderWithProps(); renderWithProps();
@@ -70,6 +88,45 @@ describe('PanelDataErrorView', () => {
expect(screen.getByText('Query returned nothing')).toBeInTheDocument(); expect(screen.getByText('Query returned nothing')).toBeInTheDocument();
}); });
it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
config.featureToggles.newVizSuggestions = true;
renderWithProps({
data: {
state: LoadingState.Done,
series: [],
timeRange: getDefaultTimeRange(),
},
});
expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument();
config.featureToggles.newVizSuggestions = originalFeatureToggle;
});
it('should show "No data" message when feature toggle is disabled even without queries', () => {
mockUsePanelContext.mockReturnValue(panelContextRoot);
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
config.featureToggles.newVizSuggestions = false;
renderWithProps({
data: {
state: LoadingState.Done,
series: [],
timeRange: getDefaultTimeRange(),
},
});
expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument();
config.featureToggles.newVizSuggestions = originalFeatureToggle;
});
}); });
function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) { function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) {
@@ -5,14 +5,15 @@ import {
FieldType, FieldType,
getPanelDataSummary, getPanelDataSummary,
GrafanaTheme2, GrafanaTheme2,
PanelData,
PanelDataSummary, PanelDataSummary,
PanelPluginVisualizationSuggestion, PanelPluginVisualizationSuggestion,
} from '@grafana/data'; } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n'; import { t, Trans } from '@grafana/i18n';
import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes'; import { VizPanel } from '@grafana/scenes';
import { usePanelContext, useStyles2 } from '@grafana/ui'; import { Icon, usePanelContext, useStyles2 } from '@grafana/ui';
import { CardButton } from 'app/core/components/CardButton'; import { CardButton } from 'app/core/components/CardButton';
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
import store from 'app/core/store'; import store from 'app/core/store';
@@ -24,6 +25,11 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo
import { useDispatch } from 'app/types/store'; import { useDispatch } from 'app/types/store';
import { changePanelPlugin } from '../state/actions'; import { changePanelPlugin } from '../state/actions';
import { hasData } from '../suggestions/utils';
function hasNoQueryConfigured(data: PanelData): boolean {
return !data.request?.targets || data.request.targets.length === 0;
}
export function PanelDataErrorView(props: PanelDataErrorViewProps) { export function PanelDataErrorView(props: PanelDataErrorViewProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@@ -93,8 +99,14 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) {
} }
}; };
const noData = !hasData(props.data);
const noQueryConfigured = hasNoQueryConfigured(props.data);
const showEmptyState =
config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData;
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{showEmptyState && <Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />}
<div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}> <div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}>
{message} {message}
</div> </div>
@@ -131,7 +143,17 @@ function getMessageFor(
return message; return message;
} }
if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) { const noData = !hasData(data);
const noQueryConfigured = hasNoQueryConfigured(data);
if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) {
return t(
'dashboard.new-panel.empty-state-message',
'Run a query to visualize it here or go to all visualizations to add other panel types'
);
}
if (noData) {
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
} }
@@ -176,5 +198,9 @@ const getStyles = (theme: GrafanaTheme2) => {
width: '100%', width: '100%',
maxWidth: '600px', maxWidth: '600px',
}), }),
emptyStateIcon: css({
color: theme.colors.text.secondary,
marginBottom: theme.spacing(2),
}),
}; };
}; };
@@ -1,29 +1,26 @@
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui'; import { RadioButtonGroup } from '@grafana/ui';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { EditorType } from '../../types'; import { EditorType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeEditorTypeAndResetQuery } from './state';
const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [ const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [
{ value: 'builder', label: 'Builder' }, { value: 'builder', label: 'Builder' },
{ value: 'code', label: 'Code' }, { value: 'code', label: 'Code' },
]; ];
export const EditorTypeSelector = () => { interface Props {
const query = useQuery(); value: EditorType;
const dispatch = useDispatch(); onChange: (editorType: EditorType) => void;
}
// Default to 'builder' if editorType is empty
const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder';
const onChange = (newEditorType: EditorType) => {
dispatch(changeEditorTypeAndResetQuery(newEditorType));
};
export const EditorTypeSelector = ({ value, onChange }: Props) => {
return ( return (
<RadioButtonGroup<EditorType> fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} /> <RadioButtonGroup<EditorType>
data-testid="elasticsearch-editor-type-toggle"
size="sm"
options={BASE_OPTIONS}
value={value}
onChange={onChange}
/>
); );
}; };
@@ -10,9 +10,13 @@ interface Props {
onRunQuery: () => void; onRunQuery: () => void;
} }
// This offset was chosen by testing to match Prometheus behavior
const EDITOR_HEIGHT_OFFSET = 2;
export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const handleEditorDidMount = useCallback( const handleEditorDidMount = useCallback(
(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
@@ -22,6 +26,22 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
onRunQuery(); onRunQuery();
}); });
// Make the editor resize itself so that the content fits (grows taller when necessary)
// this code comes from the Prometheus query editor.
// We may wish to consider abstracting it into the grafana/ui repo in the future
const updateElementHeight = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null) {
const pixelHeight = editor.getContentHeight();
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
const pixelWidth = containerDiv.clientWidth;
editor.layout({ width: pixelWidth, height: pixelHeight });
}
};
editor.onDidContentSizeChange(updateElementHeight);
updateElementHeight();
}, },
[onRunQuery] [onRunQuery]
); );
@@ -65,7 +85,17 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
return ( return (
<Box> <Box>
<div className={styles.header}> <div ref={containerRef} className={styles.editorContainer}>
<CodeEditor
value={value ?? ''}
language="json"
width="100%"
onBlur={handleQueryChange}
monacoOptions={monacoOptions}
onEditorDidMount={handleEditorDidMount}
/>
</div>
<div className={styles.footer}>
<Stack gap={1}> <Stack gap={1}>
<Button <Button
size="sm" size="sm"
@@ -76,20 +106,8 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
> >
Format Format
</Button> </Button>
<Button size="sm" variant="primary" icon="play" onClick={onRunQuery} tooltip="Run query (Ctrl/Cmd+Enter)">
Run
</Button>
</Stack> </Stack>
</div> </div>
<CodeEditor
value={value ?? ''}
language="json"
height={200}
width="100%"
onBlur={handleQueryChange}
monacoOptions={monacoOptions}
onEditorDidMount={handleEditorDidMount}
/>
</Box> </Box>
); );
} }
@@ -100,7 +118,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
flexDirection: 'column', flexDirection: 'column',
gap: theme.spacing(1), gap: theme.spacing(1),
}), }),
header: css({ editorContainer: css({
width: '100%',
overflow: 'hidden',
}),
footer: css({
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
padding: theme.spacing(0.5, 0), padding: theme.spacing(0.5, 0),
@@ -1,16 +1,16 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useEffect, useId, useState } from 'react'; import { useCallback, useEffect, useId, useState } from 'react';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; import { Alert, ConfirmModal, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui';
import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { ElasticDatasource } from '../../datasource'; import { ElasticDatasource } from '../../datasource';
import { useNextId } from '../../hooks/useNextId'; import { useNextId } from '../../hooks/useNextId';
import { useDispatch } from '../../hooks/useStatelessReducer'; import { useDispatch } from '../../hooks/useStatelessReducer';
import { ElasticsearchOptions } from '../../types'; import { EditorType, ElasticsearchOptions } from '../../types';
import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils'; import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils';
import { BucketAggregationsEditor } from './BucketAggregationsEditor'; import { BucketAggregationsEditor } from './BucketAggregationsEditor';
@@ -20,7 +20,7 @@ import { MetricAggregationsEditor } from './MetricAggregationsEditor';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
import { QueryTypeSelector } from './QueryTypeSelector'; import { QueryTypeSelector } from './QueryTypeSelector';
import { RawQueryEditor } from './RawQueryEditor'; import { RawQueryEditor } from './RawQueryEditor';
import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state'; import { changeAliasPattern, changeEditorTypeAndResetQuery, changeQuery, changeRawDSLQuery } from './state';
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>; export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>;
@@ -97,31 +97,61 @@ const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void
const inputId = useId(); const inputId = useId();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [switchModalOpen, setSwitchModalOpen] = useState(false);
const [pendingEditorType, setPendingEditorType] = useState<EditorType | null>(null);
const isTimeSeries = isTimeSeriesQuery(value); const isTimeSeries = isTimeSeriesQuery(value);
const isCodeEditor = value.editorType === 'code'; const isCodeEditor = value.editorType === 'code';
const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery; const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery;
// Default to 'builder' if editorType is empty
const currentEditorType: EditorType = value.editorType === 'code' ? 'code' : 'builder';
const showBucketAggregationsEditor = value.metrics?.every( const showBucketAggregationsEditor = value.metrics?.every(
(metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics' (metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics'
); );
const onEditorTypeChange = useCallback((newEditorType: EditorType) => {
// Show warning modal when switching modes
setPendingEditorType(newEditorType);
setSwitchModalOpen(true);
}, []);
const confirmEditorTypeChange = useCallback(() => {
if (pendingEditorType) {
dispatch(changeEditorTypeAndResetQuery(pendingEditorType));
}
setSwitchModalOpen(false);
setPendingEditorType(null);
}, [dispatch, pendingEditorType]);
const cancelEditorTypeChange = useCallback(() => {
setSwitchModalOpen(false);
setPendingEditorType(null);
}, []);
return ( return (
<> <>
<ConfirmModal
isOpen={switchModalOpen}
title="Switch editor"
body="Switching between editors will reset your query. Are you sure you want to continue?"
confirmText="Continue"
onConfirm={confirmEditorTypeChange}
onDismiss={cancelEditorTypeChange}
/>
<div className={styles.root}> <div className={styles.root}>
<InlineLabel width={17}>Query type</InlineLabel> <InlineLabel width={17}>Query type</InlineLabel>
<div className={styles.queryItem}> <div className={styles.queryItem}>
<QueryTypeSelector /> <QueryTypeSelector />
</div> </div>
</div> {rawDSLFeatureEnabled && (
{rawDSLFeatureEnabled && ( <div style={{ marginLeft: 'auto' }}>
<div className={styles.root}> <EditorTypeSelector value={currentEditorType} onChange={onEditorTypeChange} />
<InlineLabel width={17}>Editor type</InlineLabel>
<div className={styles.queryItem}>
<EditorTypeSelector />
</div> </div>
</div> )}
)} </div>
{isCodeEditor && rawDSLFeatureEnabled && ( {isCodeEditor && rawDSLFeatureEnabled && (
<RawQueryEditor <RawQueryEditor
+4 -2
View File
@@ -6383,12 +6383,15 @@
}, },
"resource-export": { "resource-export": {
"label": { "label": {
"advanced-options": "Advanced options",
"classic": "Classic", "classic": "Classic",
"json": "JSON", "json": "JSON",
"v1-resource": "V1 Resource", "v1-resource": "V1 Resource",
"v2-resource": "V2 Resource", "v2-resource": "V2 Resource",
"yaml": "YAML" "yaml": "YAML"
} },
"share-externally": "Share dashboard with another instance",
"share-externally-tooltip": "Removes all instance-specific metadata and data source references from the resource before export."
}, },
"revert-dashboard-modal": { "revert-dashboard-modal": {
"body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.", "body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.",
@@ -7842,7 +7845,6 @@
"export-externally-label": "Export the dashboard to use in another instance", "export-externally-label": "Export the dashboard to use in another instance",
"export-format": "Format", "export-format": "Format",
"export-mode": "Model", "export-mode": "Model",
"export-remove-ds-refs": "Remove deployment details",
"info-text": "Copy or download a file containing the definition of your dashboard", "info-text": "Copy or download a file containing the definition of your dashboard",
"title": "Export dashboard" "title": "Export dashboard"
}, },