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
49 changed files with 662 additions and 204 deletions
@@ -22,7 +22,7 @@ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './c
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
type RenderOptions = { type RenderOptions = {
canvasRef: RefObject<HTMLCanvasElement | null>; canvasRef: RefObject<HTMLCanvasElement>;
data: FlameGraphDataContainer; data: FlameGraphDataContainer;
root: LevelItem; root: LevelItem;
direction: 'children' | 'parents'; direction: 'children' | 'parents';
@@ -373,7 +373,7 @@ function useColorFunction(
); );
} }
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement | null>, wrapperWidth: number, numberOfLevels: number) { function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
const [ctx, setCtx] = useState<CanvasRenderingContext2D>(); const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
useEffect(() => { useEffect(() => {
@@ -7,7 +7,7 @@ const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
/** @internal */ /** @internal */
export interface UseListFocusProps { export interface UseListFocusProps {
localRef: RefObject<HTMLUListElement | null>; localRef: RefObject<HTMLUListElement>;
options: TimeOption[]; options: TimeOption[];
} }
@@ -1,7 +1,7 @@
import { RefObject, useRef } from 'react'; import { RefObject, useRef } from 'react';
export function useFocus(): [RefObject<HTMLInputElement | null>, () => void] { export function useFocus(): [RefObject<HTMLInputElement>, () => void] {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement>(null);
const setFocus = () => { const setFocus = () => {
ref.current && ref.current.focus(); ref.current && ref.current.focus();
}; };
@@ -6,7 +6,7 @@ const UNFOCUSED = -1;
/** @internal */ /** @internal */
export interface UseMenuFocusProps { export interface UseMenuFocusProps {
localRef: RefObject<HTMLDivElement | null>; localRef: RefObject<HTMLDivElement>;
isMenuOpen?: boolean; isMenuOpen?: boolean;
close?: () => void; close?: () => void;
onOpen?: (focusOnItem: (itemId: number) => void) => void; onOpen?: (focusOnItem: (itemId: number) => void) => void;
@@ -22,7 +22,7 @@ interface Props extends Omit<BoxProps, 'display' | 'direction' | 'element' | 'fl
* *
* https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-scrollcontainer--docs * https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-scrollcontainer--docs
*/ */
export const ScrollContainer = forwardRef<HTMLDivElement | null, PropsWithChildren<Props>>( export const ScrollContainer = forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
( (
{ {
children, children,
@@ -20,7 +20,7 @@ export interface TableCellTooltipProps {
field: Field; field: Field;
getActions: (field: Field, rowIdx: number) => ActionModel[]; getActions: (field: Field, rowIdx: number) => ActionModel[];
getTextColorForBackground: (bgColor: string) => string; getTextColorForBackground: (bgColor: string) => string;
gridRef: RefObject<DataGridHandle | null>; gridRef: RefObject<DataGridHandle>;
height: number; height: number;
placement?: TableCellTooltipPlacement; placement?: TableCellTooltipPlacement;
renderer: TableCellRenderer; renderer: TableCellRenderer;
@@ -463,7 +463,7 @@ export function useColumnResize(
return dataGridResizeHandler; return dataGridResizeHandler;
} }
export function useScrollbarWidth(ref: RefObject<DataGridHandle | null>, height: number) { export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
const [scrollbarWidth, setScrollbarWidth] = useState(0); const [scrollbarWidth, setScrollbarWidth] = useState(0);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -135,7 +135,7 @@ export const Table = memo((props: Props) => {
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns // `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time // `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
// we initialize `useTableStateReducer`. // we initialize `useTableStateReducer`.
const toggleAllRowsExpandedRef = useRef<((value?: boolean) => void) | null>(null); const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
// Internal react table state reducer // Internal react table state reducer
const stateReducer = useTableStateReducer({ const stateReducer = useTableStateReducer({
@@ -14,8 +14,8 @@ import { GrafanaTableState } from './types';
Select the scrollbar element from the VariableSizeList scope Select the scrollbar element from the VariableSizeList scope
*/ */
export function useFixScrollbarContainer( export function useFixScrollbarContainer(
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement | null>, variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
tableDivRef: React.RefObject<HTMLDivElement | null> tableDivRef: React.RefObject<HTMLDivElement>
) { ) {
useEffect(() => { useEffect(() => {
if (variableSizeListScrollbarRef.current && tableDivRef.current) { if (variableSizeListScrollbarRef.current && tableDivRef.current) {
@@ -43,7 +43,7 @@ export function useFixScrollbarContainer(
*/ */
export function useResetVariableListSizeCache( export function useResetVariableListSizeCache(
extendedState: GrafanaTableState, extendedState: GrafanaTableState,
listRef: React.RefObject<VariableSizeList | null>, listRef: React.RefObject<VariableSizeList>,
data: DataFrame, data: DataFrame,
hasUniqueId: boolean hasUniqueId: boolean
) { ) {
@@ -19,7 +19,7 @@ interface EventsCanvasProps {
} }
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) { export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change // render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
// so we need to force the re-render when the draw hook was performed by uPlot // so we need to force the re-render when the draw hook was performed by uPlot
const [renderToken, setRenderToken] = useState(0); const [renderToken, setRenderToken] = useState(0);
@@ -140,7 +140,7 @@ export const TooltipPlugin2 = ({
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState); const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
const sizeRef = useRef<TooltipContainerSize | null>(null); const sizeRef = useRef<TooltipContainerSize>();
const styles = useStyles2(getStyles, maxWidth); const styles = useStyles2(getStyles, maxWidth);
const renderRef = useRef(render); const renderRef = useRef(render);
@@ -96,7 +96,7 @@ export interface GraphNGState {
export class GraphNG extends Component<GraphNGProps, GraphNGState> { export class GraphNG extends Component<GraphNGProps, GraphNGState> {
static contextType = PanelContextRoot; static contextType = PanelContextRoot;
panelContext: PanelContext = {} as PanelContext; panelContext: PanelContext = {} as PanelContext;
private plotInstance: React.RefObject<uPlot | null>; private plotInstance: React.RefObject<uPlot>;
private subscription = new Subscription(); private subscription = new Subscription();
+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
@@ -109,7 +109,7 @@ const defaultMatchers = {
* "Time as X" core component, expects ascending x * "Time as X" core component, expects ascending x
*/ */
export class GraphNG extends Component<GraphNGProps, GraphNGState> { export class GraphNG extends Component<GraphNGProps, GraphNGState> {
private plotInstance: React.RefObject<uPlot | null>; private plotInstance: React.RefObject<uPlot>;
constructor(props: GraphNGProps) { constructor(props: GraphNGProps) {
super(props); super(props);
@@ -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;
} }
@@ -33,7 +33,7 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
}); });
// Helper function to assign a mock div to a ref // Helper function to assign a mock div to a ref
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement | null>, mockDiv: HTMLDivElement) { function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
// Use type assertion to bypass readonly restriction in tests // Use type assertion to bypass readonly restriction in tests
(ref as { current: HTMLDivElement | null }).current = mockDiv; (ref as { current: HTMLDivElement | null }).current = mockDiv;
} }
@@ -8,7 +8,7 @@ import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg'; import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
interface SoloPanelPageLogoProps { interface SoloPanelPageLogoProps {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement>;
isHovered: boolean; isHovered: boolean;
hideLogo?: UrlQueryValue; hideLogo?: UrlQueryValue;
} }
@@ -61,7 +61,7 @@ interface State {
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> { class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable; subscription?: Unsubscribable;
ref: RefObject<HTMLDivElement | null>; ref: RefObject<HTMLDivElement>;
constructor(props: TransformationsEditorProps) { constructor(props: TransformationsEditorProps) {
super(props); super(props);
@@ -43,7 +43,7 @@ export const LibraryPanelsView = ({
} }
); );
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]); const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController>();
useDebounce( useDebounce(
() => { () => {
@@ -12,7 +12,7 @@ export interface Props {}
export const LiveConnectionWarning = memo(function LiveConnectionWarning() { export const LiveConnectionWarning = memo(function LiveConnectionWarning() {
const [show, setShow] = useState<boolean | undefined>(undefined); const [show, setShow] = useState<boolean | undefined>(undefined);
const subscriptionRef = useRef<Unsubscribable | null>(null); const subscriptionRef = useRef<Unsubscribable>();
const styles = useStyles2(getStyle); const styles = useStyles2(getStyle);
useEffect(() => { useEffect(() => {
@@ -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),
}),
}; };
}; };
@@ -38,7 +38,7 @@ export function useSearchKeyboardNavigation(
): ItemSelection { ): ItemSelection {
const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 }); const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 });
const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 }); const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 });
const urlsRef = useRef<Field | null>(null); const urlsRef = useRef<Field>();
// Clear selection when the search results change // Clear selection when the search results change
useEffect(() => { useEffect(() => {
@@ -77,7 +77,7 @@ export const SuggestionsInput = ({
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, inputHeight); const styles = getStyles(theme, inputHeight);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop); scrollRef.current?.scrollTo(0, scrollTop);
@@ -11,7 +11,7 @@ import checkboxWhitePng from 'img/checkbox_white.png';
import { ALL_VARIABLE_VALUE } from '../../constants'; import { ALL_VARIABLE_VALUE } from '../../constants';
export interface Props extends Omit<React.HTMLProps<HTMLUListElement>, 'onToggle'>, Themeable2 { export interface Props extends React.HTMLProps<HTMLUListElement>, Themeable2 {
multi: boolean; multi: boolean;
values: VariableOption[]; values: VariableOption[];
selectedValues: VariableOption[]; selectedValues: VariableOption[];
@@ -20,8 +20,8 @@ interface CodeEditorProps {
export const LogsQLCodeEditor = (props: CodeEditorProps) => { export const LogsQLCodeEditor = (props: CodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(
@@ -42,8 +42,8 @@ interface LogsCodeEditorProps {
export const PPLQueryEditor = (props: LogsCodeEditorProps) => { export const PPLQueryEditor = (props: LogsCodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(
@@ -20,8 +20,8 @@ interface SQLCodeEditorProps {
export const SQLQueryEditor = (props: SQLCodeEditorProps) => { export const SQLQueryEditor = (props: SQLCodeEditorProps) => {
const { query, datasource, onChange } = props; const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined); const disposalRef = useRef<monacoType.IDisposable>();
const onFocus = useCallback(async () => { const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider( disposalRef.current = await reRegisterCompletionProvider(
@@ -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
@@ -94,7 +94,7 @@ const EDITOR_HEIGHT_OFFSET = 2;
* Hook that returns function that will set up monaco autocomplete for the label selector * Hook that returns function that will set up monaco autocomplete for the label selector
*/ */
function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) { function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) {
const providerRef = useRef<CompletionProvider | null>(null); const providerRef = useRef<CompletionProvider>();
if (providerRef.current === undefined) { if (providerRef.current === undefined) {
providerRef.current = new CompletionProvider(); providerRef.current = new CompletionProvider();
} }
@@ -126,7 +126,7 @@ export function LokiContextUi(props: LokiContextUiProps) {
window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true' window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true'
); );
const timerHandle = useRef<number | null>(null); const timerHandle = useRef<number>();
const previousInitialized = useRef<boolean>(false); const previousInitialized = useRef<boolean>(false);
const previousContextFilters = useRef<ContextFilter[]>([]); const previousContextFilters = useRef<ContextFilter[]>([]);
@@ -191,18 +191,14 @@ export function LokiContextUi(props: LokiContextUiProps) {
}, 1500); }, 1500);
return () => { return () => {
if (timerHandle.current) { clearTimeout(timerHandle.current);
clearTimeout(timerHandle.current);
}
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextFilters, initialized]); }, [contextFilters, initialized]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timerHandle.current) { clearTimeout(timerHandle.current);
clearTimeout(timerHandle.current);
}
onClose(); onClose();
}; };
}, [onClose]); }, [onClose]);
@@ -52,7 +52,7 @@ export function TraceQLEditor(props: Props) {
const onRunQueryRef = useRef(onRunQuery); const onRunQueryRef = useRef(onRunQuery);
onRunQueryRef.current = onRunQuery; onRunQueryRef.current = onRunQuery;
const errorTimeoutId = useRef<number | null>(null); const errorTimeoutId = useRef<number>();
return ( return (
<> <>
@@ -103,9 +103,7 @@ export function TraceQLEditor(props: Props) {
} }
// Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing // Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing
if (errorTimeoutId.current) { window.clearTimeout(errorTimeoutId.current);
window.clearTimeout(errorTimeoutId.current);
}
const errorNodes = getErrorNodes(model.getValue()); const errorNodes = getErrorNodes(model.getValue());
const cursorPosition = changeEvent.changes[0].rangeOffset; const cursorPosition = changeEvent.changes[0].rangeOffset;
@@ -1,5 +1,5 @@
export function renderHistogram( export function renderHistogram(
can: React.RefObject<HTMLCanvasElement | null>, can: React.RefObject<HTMLCanvasElement>,
histCanWidth: number, histCanWidth: number,
histCanHeight: number, histCanHeight: number,
xVals: number[], xVals: number[],
+1 -1
View File
@@ -196,7 +196,7 @@ export const LogsPanel = ({
const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets); const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets);
// Prevents the scroll position to change when new data from infinite scrolling is received // Prevents the scroll position to change when new data from infinite scrolling is received
const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null); const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null);
const closeCallback = useRef<(() => void) | null>(null); let closeCallback = useRef<() => void>();
const { app, eventBus, onAddAdHocFilter } = usePanelContext(); const { app, eventBus, onAddAdHocFilter } = usePanelContext();
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -70,7 +70,7 @@ export function useLayout(
const currentSignature = createDataSignature(rawNodes, rawEdges); const currentSignature = createDataSignature(rawNodes, rawEdges);
const isMounted = useMountedState(); const isMounted = useMountedState();
const layoutWorkerCancelRef = useRef<(() => void) | null>(null); const layoutWorkerCancelRef = useRef<(() => void) | undefined>();
useUnmount(() => { useUnmount(() => {
if (layoutWorkerCancelRef.current) { if (layoutWorkerCancelRef.current) {
@@ -133,7 +133,7 @@ export const AnnotationsPlugin2 = ({
const newRangeRef = useRef(newRange); const newRangeRef = useRef(newRange);
newRangeRef.current = newRange; newRangeRef.current = newRange;
const xAxisRef = useRef<HTMLDivElement | null>(null); const xAxisRef = useRef<HTMLDivElement>();
useLayoutEffect(() => { useLayoutEffect(() => {
config.addHook('ready', (u) => { config.addHook('ready', (u) => {
@@ -23,7 +23,7 @@ export const ExemplarsPlugin = ({
maxHeight, maxHeight,
maxWidth, maxWidth,
}: ExemplarsPluginProps) => { }: ExemplarsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>(); const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>();
@@ -11,7 +11,7 @@ interface ThresholdControlsPluginProps {
} }
export const OutsideRangePlugin = ({ config, onChangeTimeRange }: ThresholdControlsPluginProps) => { export const OutsideRangePlugin = ({ config, onChangeTimeRange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [timevalues, setTimeValues] = useState<number[] | TypedArray>([]); const [timevalues, setTimeValues] = useState<number[] | TypedArray>([]);
const [timeRange, setTimeRange] = useState<Scale | undefined>(); const [timeRange, setTimeRange] = useState<Scale | undefined>();
@@ -16,7 +16,7 @@ interface ThresholdControlsPluginProps {
} }
export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChange }: ThresholdControlsPluginProps) => { export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot | null>(null); const plotInstance = useRef<uPlot>();
const [renderToken, setRenderToken] = useState(0); const [renderToken, setRenderToken] = useState(0);
useLayoutEffect(() => { useLayoutEffect(() => {
+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"
}, },