diff --git a/.betterer.results b/.betterer.results index 6d1ccb5383c..96b2c33454a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5265,13 +5265,13 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "10"], [0, 0, 0, "Do not use any type assertions.", "11"] ], - "public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx:5381": [ + "public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/trails/ActionTabs/BreakdownScene.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + "public/app/features/trails/Breakdown/types.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/trails/ActionTabs/utils.ts:5381": [ + "public/app/features/trails/Breakdown/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/trails/DataTrailCard.tsx:5381": [ diff --git a/public/app/features/trails/ActionTabs/ByFrameRepeater.tsx b/public/app/features/trails/ActionTabs/ByFrameRepeater.tsx deleted file mode 100644 index be960f6afc3..00000000000 --- a/public/app/features/trails/ActionTabs/ByFrameRepeater.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { LoadingState, PanelData, DataFrame } from '@grafana/data'; -import { - SceneObjectState, - SceneFlexItem, - SceneObjectBase, - sceneGraph, - SceneComponentProps, - SceneLayout, - SceneDataNode, -} from '@grafana/scenes'; - -import { StatusWrapper } from '../StatusWrapper'; - -import { findSceneObjectsByType } from './utils'; - -interface ByFrameRepeaterState extends SceneObjectState { - body: SceneLayout; - getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem; -} - -export class ByFrameRepeater extends SceneObjectBase { - public constructor(state: ByFrameRepeaterState) { - super(state); - - this.addActivationHandler(() => { - const data = sceneGraph.getData(this); - - this._subs.add( - data.subscribeToState((newState, oldState) => { - if (newState.data === undefined) { - return; - } - - const newData = newState.data; - - if (newState.data !== undefined && newState.data?.state !== oldState.data?.state) { - findSceneObjectsByType(this, SceneDataNode).forEach((dataNode) => { - dataNode.setState({ data: { ...dataNode.state.data, state: newData.state } }); - }); - } - if (newData.state === LoadingState.Done) { - this.performRepeat(newData); - } - }) - ); - - if (data.state.data) { - this.performRepeat(data.state.data); - } - }); - } - - private performRepeat(data: PanelData) { - const newChildren: SceneFlexItem[] = []; - - for (let seriesIndex = 0; seriesIndex < data.series.length; seriesIndex++) { - const frame = data.series[seriesIndex]; - if (frame.length <= 1) { - // If the data doesn't have at least two points, we skip it. - continue; - } - const layoutChild = this.state.getLayoutChild(data, frame, seriesIndex); - newChildren.push(layoutChild); - } - - this.state.body.setState({ children: newChildren }); - this.setState({ body: this.state.body }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { body } = model.useState(); - const { children } = body.useState(); - - const data = sceneGraph.getData(model); - const sceneDataState = data.useState(); - - const panelData = sceneDataState?.data; - - const isLoading = panelData?.state === 'Loading' && children.length === 0; - const error = panelData?.state === LoadingState.Error ? 'Failed to load data.' : undefined; - const blockingMessage = - !isLoading && children.length === 0 && !error - ? 'There is no data available. Try adjusting your filters, time range, or selecting a different label.' - : undefined; - - return ( - - - - ); - }; -} diff --git a/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx b/public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx similarity index 100% rename from public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx rename to public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx diff --git a/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx b/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx new file mode 100644 index 00000000000..e0a145416ad --- /dev/null +++ b/public/app/features/trails/Breakdown/BreakdownSearchScene.tsx @@ -0,0 +1,68 @@ +import { ChangeEvent } from 'react'; + +import { BusEventBase } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; + +import { ByFrameRepeater } from './ByFrameRepeater'; +import { LabelBreakdownScene } from './LabelBreakdownScene'; +import { SearchInput } from './SearchInput'; + +export class BreakdownSearchReset extends BusEventBase { + public static type = 'breakdown-search-reset'; +} + +export interface BreakdownSearchSceneState extends SceneObjectState { + filter?: string; +} + +const recentFilters: Record = {}; + +export class BreakdownSearchScene extends SceneObjectBase { + private cacheKey: string; + + constructor(cacheKey: string) { + super({ + filter: recentFilters[cacheKey] ?? '', + }); + this.cacheKey = cacheKey; + } + + public static Component = ({ model }: SceneComponentProps) => { + const { filter } = model.useState(); + return ( + + ); + }; + + public onValueFilterChange = (event: ChangeEvent) => { + this.setState({ filter: event.target.value }); + this.filterValues(event.target.value); + }; + + public clearValueFilter = () => { + this.setState({ filter: '' }); + this.filterValues(''); + }; + + public reset = () => { + this.setState({ filter: '' }); + recentFilters[this.cacheKey] = ''; + }; + + private filterValues(filter: string) { + if (this.parent instanceof LabelBreakdownScene) { + recentFilters[this.cacheKey] = filter; + const body = this.parent.state.body; + body?.forEachChild((child) => { + if (child instanceof ByFrameRepeater && child.state.body.isActive) { + child.filterByString(filter); + } + }); + } + } +} diff --git a/public/app/features/trails/Breakdown/ByFrameRepeater.tsx b/public/app/features/trails/Breakdown/ByFrameRepeater.tsx new file mode 100644 index 00000000000..3959537d84f --- /dev/null +++ b/public/app/features/trails/Breakdown/ByFrameRepeater.tsx @@ -0,0 +1,189 @@ +import { css } from '@emotion/css'; + +import { DataFrame, LoadingState, PanelData } from '@grafana/data'; +import { + SceneByFrameRepeater, + SceneComponentProps, + SceneDataNode, + SceneFlexItem, + SceneFlexLayout, + sceneGraph, + SceneLayout, + SceneObjectBase, + SceneObjectState, + SceneReactObject, +} from '@grafana/scenes'; +import { Alert, Button } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { getLabelValueFromDataFrame } from '../services/levels'; +import { fuzzySearch } from '../services/search'; + +import { BreakdownSearchReset } from './BreakdownSearchScene'; +import { findSceneObjectsByType } from './utils'; + +interface ByFrameRepeaterState extends SceneObjectState { + body: SceneLayout; + + getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem; +} + +type FrameFilterCallback = (frame: DataFrame) => boolean; +type FrameIterateCallback = (frames: DataFrame[], seriesIndex: number) => void; + +export class ByFrameRepeater extends SceneObjectBase { + private unfilteredChildren: SceneFlexItem[] = []; + private series: DataFrame[] = []; + private getFilter: () => string; + + public constructor({ getFilter, ...state }: ByFrameRepeaterState & { getFilter: () => string }) { + super(state); + + this.getFilter = getFilter; + + this.addActivationHandler(() => { + const data = sceneGraph.getData(this); + + this._subs.add( + data.subscribeToState((newState, oldState) => { + if (newState.data === undefined) { + return; + } + + const newData = newState.data; + + if (newState.data?.state !== oldState.data?.state) { + findSceneObjectsByType(this, SceneDataNode).forEach((dataNode) => { + dataNode.setState({ data: { ...dataNode.state.data, state: newData.state } }); + }); + } + if (newData.state === LoadingState.Done) { + this.performRepeat(newData); + } + }) + ); + + if (data.state.data) { + this.performRepeat(data.state.data); + } + }); + } + + private performRepeat(data: PanelData) { + const newChildren: SceneFlexItem[] = []; + this.series = data.series; + + for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { + const layoutChild = this.state.getLayoutChild(data, this.series[seriesIndex], seriesIndex); + newChildren.push(layoutChild); + } + + this.unfilteredChildren = newChildren; + + if (this.getFilter()) { + this.state.body.setState({ children: [] }); + this.filterByString(this.getFilter()); + } else { + this.state.body.setState({ children: newChildren }); + } + } + + filterByString = (filter: string) => { + let haystack: string[] = []; + + this.iterateFrames((frames, seriesIndex) => { + const labelValue = getLabelValue(frames[seriesIndex]); + haystack.push(labelValue); + }); + fuzzySearch(haystack, filter, (data) => { + if (data && data[0]) { + // We got search results + this.filterFrames((frame: DataFrame) => { + const label = getLabelValue(frame); + return data[0].includes(label); + }); + } else { + // reset search + this.filterFrames(() => true); + } + }); + }; + + public iterateFrames = (callback: FrameIterateCallback) => { + const data = sceneGraph.getData(this).state.data; + if (!data) { + return; + } + for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { + callback(this.series, seriesIndex); + } + }; + + public filterFrames = (filterFn: FrameFilterCallback) => { + const newChildren: SceneFlexItem[] = []; + this.iterateFrames((frames, seriesIndex) => { + if (filterFn(frames[seriesIndex])) { + newChildren.push(this.unfilteredChildren[seriesIndex]); + } + }); + + if (newChildren.length === 0) { + this.state.body.setState({ children: [buildNoResultsScene(this.getFilter(), this.clearFilter)] }); + } else { + this.state.body.setState({ children: newChildren }); + } + }; + + public clearFilter = () => { + this.publishEvent(new BreakdownSearchReset(), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + return ; + }; +} + +function buildNoResultsScene(filter: string, clearFilter: () => void) { + return new SceneFlexLayout({ + direction: 'row', + children: [ + new SceneFlexItem({ + body: new SceneReactObject({ + reactNode: ( +
+ + + No values found matching; {{ filter }} + + + +
+ ), + }), + }), + ], + }); +} + +const styles = { + alertContainer: css({ + flexGrow: 1, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + noResultsAlert: css({ + minWidth: '30vw', + flexGrow: 0, + }), + clearButton: css({ + marginLeft: '1.5rem', + }), +}; + +function getLabelValue(frame: DataFrame) { + return getLabelValueFromDataFrame(frame) ?? 'No labels'; +} diff --git a/public/app/features/trails/ActionTabs/BreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx similarity index 84% rename from public/app/features/trails/ActionTabs/BreakdownScene.tsx rename to public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index c688f2fe106..fc1b8d3f9c8 100644 --- a/public/app/features/trails/ActionTabs/BreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { min, max, isNumber, throttle } from 'lodash'; +import { isNumber, max, min, throttle } from 'lodash'; import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { @@ -17,12 +17,14 @@ import { SceneObjectBase, SceneObjectState, SceneQueryRunner, + SceneReactObject, VariableDependencyConfig, VizPanel, } from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; -import { Button, Field, useStyles2 } from '@grafana/ui'; -import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { Field } from '@grafana/ui/'; +import { Trans } from 'app/core/internationalization'; import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine'; import { AutoQueryDef } from '../AutomaticMetricQueries/types'; @@ -30,10 +32,12 @@ import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; +import { ALL_VARIABLE_VALUE } from '../services/variables'; import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; import { getColorByIndex, getTrailFor } from '../utils'; import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; +import { BreakdownSearchReset, BreakdownSearchScene } from './BreakdownSearchScene'; import { ByFrameRepeater } from './ByFrameRepeater'; import { LayoutSwitcher } from './LayoutSwitcher'; import { breakdownPanelOptions } from './panelConfigs'; @@ -43,8 +47,9 @@ import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60; -export interface BreakdownSceneState extends SceneObjectState { - body?: SceneObject; +export interface LabelBreakdownSceneState extends SceneObjectState { + body?: LayoutSwitcher; + search: BreakdownSearchScene; labels: Array>; value?: string; loading?: boolean; @@ -52,16 +57,17 @@ export interface BreakdownSceneState extends SceneObjectState { blockingMessage?: string; } -export class BreakdownScene extends SceneObjectBase { +export class LabelBreakdownScene extends SceneObjectBase { protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [VAR_FILTERS], onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), }); - constructor(state: Partial) { + constructor(state: Partial) { super({ - labels: state.labels ?? [], ...state, + labels: state.labels ?? [], + search: new BreakdownSearchScene('labels'), }); this.addActivationHandler(this._onActivate.bind(this)); @@ -82,6 +88,12 @@ export class BreakdownScene extends SceneObjectBase { } }); + this._subs.add( + this.subscribeToEvent(BreakdownSearchReset, () => { + this.state.search.clearValueFilter(); + }) + ); + const metricScene = sceneGraph.getAncestor(this, MetricScene); const metric = metricScene.state.metric; this._query = getAutoQueriesForMetric(metric).breakdown; @@ -103,6 +115,7 @@ export class BreakdownScene extends SceneObjectBase { private breakdownPanelMaxValue: number | undefined; private breakdownPanelMinValue: number | undefined; + public reportBreakdownPanelData(data: PanelData | undefined) { if (!data) { return; @@ -170,7 +183,7 @@ export class BreakdownScene extends SceneObjectBase { private updateBody(variable: QueryVariable) { const options = getLabelOptions(this, variable); - const stateUpdate: Partial = { + const stateUpdate: Partial = { loading: variable.state.loading, value: String(variable.state.value), labels: options, @@ -181,7 +194,7 @@ export class BreakdownScene extends SceneObjectBase { if (!variable.state.loading && variable.state.options.length) { stateUpdate.body = variable.hasAllValue() ? buildAllLayout(options, this._query!, this.onBreakdownLayoutChange) - : buildNormalLayout(this._query!, this.onBreakdownLayoutChange); + : buildNormalLayout(this._query!, this.onBreakdownLayoutChange, this.state.search); } else if (!variable.state.loading) { stateUpdate.body = undefined; stateUpdate.blockingMessage = 'Unable to retrieve label options for currently selected metric.'; @@ -207,8 +220,8 @@ export class BreakdownScene extends SceneObjectBase { variable.changeValueTo(value); }; - public static Component = ({ model }: SceneComponentProps) => { - const { labels, body, loading, value, blockingMessage } = model.useState(); + public static Component = ({ model }: SceneComponentProps) => { + const { labels, body, search, loading, value, blockingMessage } = model.useState(); const styles = useStyles2(getStyles); const { useOtelExperience } = getTrailFor(model).useState(); @@ -218,16 +231,20 @@ export class BreakdownScene extends SceneObjectBase {
{!loading && labels.length && ( -
- - - -
+ + + + )} + + {value !== ALL_VARIABLE_VALUE && ( + + + )} {body instanceof LayoutSwitcher && ( -
+ -
+ )}
{body && }
@@ -244,29 +261,22 @@ function getStyles(theme: GrafanaTheme2) { display: 'flex', minHeight: '100%', flexDirection: 'column', + paddingTop: theme.spacing(1), }), content: css({ flexGrow: 1, display: 'flex', paddingTop: theme.spacing(0), }), + searchField: css({ + flexGrow: 1, + }), controls: css({ flexGrow: 0, display: 'flex', - alignItems: 'top', + alignItems: 'flex-end', gap: theme.spacing(2), - }), - controlsRight: css({ - flexGrow: 0, - display: 'flex', - justifyContent: 'flex-end', - }), - controlsLeft: css({ - display: 'flex', - justifyContent: 'flex-left', - justifyItems: 'left', - width: '100%', - flexDirection: 'column', + justifyContent: 'space-between', }), }; } @@ -347,7 +357,11 @@ export function buildAllLayout( const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; -function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: BreakdownLayoutChangeCallback) { +function buildNormalLayout( + queryDef: AutoQueryDef, + onBreakdownLayoutChange: BreakdownLayoutChangeCallback, + searchScene: BreakdownSearchScene +) { const unit = queryDef.unit; function getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem { @@ -376,6 +390,8 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea return item; } + const getFilter = () => searchScene.state.filter ?? ''; + return new LayoutSwitcher({ $data: new SceneQueryRunner({ datasource: trailDS, @@ -402,9 +418,16 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea body: new SceneCSSGridLayout({ templateColumns: GRID_TEMPLATE_COLUMNS, autoRows: '200px', - children: [], + children: [ + new SceneFlexItem({ + body: new SceneReactObject({ + reactNode: , + }), + }), + ], }), getLayoutChild, + getFilter, }), new ByFrameRepeater({ body: new SceneCSSGridLayout({ @@ -413,6 +436,7 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea children: [], }), getLayoutChild, + getFilter, }), ], }); @@ -429,13 +453,14 @@ function getLabelValue(frame: DataFrame) { return labels[keys[0]]; } -export function buildBreakdownActionScene() { - return new BreakdownScene({}); +export function buildLabelBreakdownActionScene() { + return new LabelBreakdownScene({}); } interface SelectLabelActionState extends SceneObjectState { labelName: string; } + export class SelectLabelAction extends SceneObjectBase { public onClick = () => { const label = this.state.labelName; @@ -446,14 +471,14 @@ export class SelectLabelAction extends SceneObjectBase { public static Component = ({ model }: SceneComponentProps) => { return ( ); }; } -function getBreakdownSceneFor(model: SceneObject): BreakdownScene { - if (model instanceof BreakdownScene) { +function getBreakdownSceneFor(model: SceneObject): LabelBreakdownScene { + if (model instanceof LabelBreakdownScene) { return model; } diff --git a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx b/public/app/features/trails/Breakdown/LayoutSwitcher.tsx similarity index 81% rename from public/app/features/trails/ActionTabs/LayoutSwitcher.tsx rename to public/app/features/trails/Breakdown/LayoutSwitcher.tsx index 6c2bd614643..93dfb59de01 100644 --- a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx +++ b/public/app/features/trails/Breakdown/LayoutSwitcher.tsx @@ -8,12 +8,13 @@ import { SceneObjectUrlValues, SceneObjectWithUrlSync, } from '@grafana/scenes'; -import { Field, RadioButtonGroup } from '@grafana/ui'; +import { RadioButtonGroup } from '@grafana/ui'; import { reportExploreMetrics } from '../interactions'; -import { MakeOptional, TRAIL_BREAKDOWN_VIEW_KEY } from '../shared'; +import { getVewByPreference, setVewByPreference } from '../services/store'; +import { MakeOptional } from '../shared'; -import { isBreakdownLayoutType, BreakdownLayoutChangeCallback, BreakdownLayoutType } from './types'; +import { BreakdownLayoutChangeCallback, BreakdownLayoutType, isBreakdownLayoutType } from './types'; export interface LayoutSwitcherState extends SceneObjectState { activeBreakdownLayout: BreakdownLayoutType; @@ -26,7 +27,7 @@ export class LayoutSwitcher extends SceneObjectBase impleme protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['breakdownLayout'] }); public constructor(state: MakeOptional) { - const storedBreakdownLayout = localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY); + const storedBreakdownLayout = getVewByPreference(); super({ activeBreakdownLayout: isBreakdownLayoutType(storedBreakdownLayout) ? storedBreakdownLayout : 'grid', ...state, @@ -50,13 +51,11 @@ export class LayoutSwitcher extends SceneObjectBase impleme const { activeBreakdownLayout, breakdownLayoutOptions } = model.useState(); return ( - - - + ); } @@ -66,7 +65,7 @@ export class LayoutSwitcher extends SceneObjectBase impleme } reportExploreMetrics('breakdown_layout_changed', { layout: active }); - localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, active); + setVewByPreference(active); this.setState({ activeBreakdownLayout: active }); this.state.onBreakdownLayoutChange(active); }; diff --git a/public/app/features/trails/Breakdown/SearchInput.tsx b/public/app/features/trails/Breakdown/SearchInput.tsx new file mode 100644 index 00000000000..69342d933bf --- /dev/null +++ b/public/app/features/trails/Breakdown/SearchInput.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/css'; +import { HTMLProps } from 'react'; + +import { Icon, Input } from '@grafana/ui'; + +interface Props extends Omit, 'width'> { + onClear(): void; +} + +export const SearchInput = ({ value, onChange, placeholder, onClear, ...rest }: Props) => { + return ( + : undefined + } + prefix={} + placeholder={placeholder} + {...rest} + /> + ); +}; + +const styles = { + clearIcon: css({ + cursor: 'pointer', + }), +}; diff --git a/public/app/features/trails/ActionTabs/panelConfigs.ts b/public/app/features/trails/Breakdown/panelConfigs.ts similarity index 100% rename from public/app/features/trails/ActionTabs/panelConfigs.ts rename to public/app/features/trails/Breakdown/panelConfigs.ts diff --git a/public/app/features/trails/Breakdown/types.test.ts b/public/app/features/trails/Breakdown/types.test.ts new file mode 100644 index 00000000000..f69efd84c0c --- /dev/null +++ b/public/app/features/trails/Breakdown/types.test.ts @@ -0,0 +1,21 @@ +import { isBreakdownLayoutType } from './types'; + +describe('types', () => { + it('isBreakdownLayoutType should return true for rows', () => { + const expected = true; + const result = isBreakdownLayoutType('rows'); + expect(result).toBe(expected); + }); + + it('isBreakdownLayoutType should return false for undefined', () => { + const expected = false; + const result1 = isBreakdownLayoutType(undefined); + const result2 = isBreakdownLayoutType('undefined'); + const result3 = isBreakdownLayoutType(null); + const result4 = isBreakdownLayoutType('null'); + expect(result1).toBe(expected); + expect(result2).toBe(expected); + expect(result3).toBe(expected); + expect(result4).toBe(expected); + }); +}); diff --git a/public/app/features/trails/ActionTabs/types.ts b/public/app/features/trails/Breakdown/types.ts similarity index 81% rename from public/app/features/trails/ActionTabs/types.ts rename to public/app/features/trails/Breakdown/types.ts index 4ee5fa1c5ee..d8f9833ee64 100644 --- a/public/app/features/trails/ActionTabs/types.ts +++ b/public/app/features/trails/Breakdown/types.ts @@ -5,7 +5,7 @@ export type BreakdownLayoutType = (typeof BREAKDOWN_LAYOUT_TYPES)[number]; export function isBreakdownLayoutType( breakdownLayoutType: string | null | undefined ): breakdownLayoutType is BreakdownLayoutType { - return !!breakdownLayoutType && breakdownLayoutType in BREAKDOWN_LAYOUT_TYPES; + return BREAKDOWN_LAYOUT_TYPES.includes(breakdownLayoutType as BreakdownLayoutType); } export type BreakdownLayoutChangeCallback = (newBreakdownLayout: BreakdownLayoutType) => void; diff --git a/public/app/features/trails/ActionTabs/utils.ts b/public/app/features/trails/Breakdown/utils.ts similarity index 100% rename from public/app/features/trails/ActionTabs/utils.ts rename to public/app/features/trails/Breakdown/utils.ts diff --git a/public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts b/public/app/features/trails/Breakdown/yAxisSyncBehavior.ts similarity index 94% rename from public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts rename to public/app/features/trails/Breakdown/yAxisSyncBehavior.ts index f42baf2e68d..cf56d78be11 100644 --- a/public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts +++ b/public/app/features/trails/Breakdown/yAxisSyncBehavior.ts @@ -3,12 +3,12 @@ import { FieldConfigBuilders, SceneCSSGridItem, SceneDataProvider, + sceneGraph, SceneStatelessBehavior, VizPanel, - sceneGraph, } from '@grafana/scenes'; -import { BreakdownScene } from './BreakdownScene'; +import { LabelBreakdownScene } from './LabelBreakdownScene'; import { findSceneObjectsByType } from './utils'; export class BreakdownAxisChangeEvent extends BusEventWithPayload<{ min: number; max: number }> { @@ -16,7 +16,7 @@ export class BreakdownAxisChangeEvent extends BusEventWithPayload<{ min: number; } export const yAxisSyncBehavior: SceneStatelessBehavior = (sceneObject: SceneCSSGridItem) => { - const breakdownScene = sceneGraph.getAncestor(sceneObject, BreakdownScene); + const breakdownScene = sceneGraph.getAncestor(sceneObject, LabelBreakdownScene); // Handle query runners from vizPanels that haven't been activated yet findSceneObjectsByType(sceneObject, VizPanel).forEach((vizPanel) => { diff --git a/public/app/features/trails/BreakdownLabelSelector.tsx b/public/app/features/trails/BreakdownLabelSelector.tsx index b0fca78e80d..4157b0dc73e 100644 --- a/public/app/features/trails/BreakdownLabelSelector.tsx +++ b/public/app/features/trails/BreakdownLabelSelector.tsx @@ -1,9 +1,7 @@ import { css } from '@emotion/css'; -import { useResizeObserver } from '@react-aria/utils'; -import { useEffect, useRef, useState } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Select, RadioButtonGroup, useStyles2, useTheme2, measureText } from '@grafana/ui'; +import { Select, useStyles2 } from '@grafana/ui'; type Props = { options: Array>; @@ -13,42 +11,8 @@ type Props = { export function BreakdownLabelSelector({ options, value, onChange }: Props) { const styles = useStyles2(getStyles); - const theme = useTheme2(); - const [labelSelectorRequiredWidth, setLabelSelectorRequiredWidth] = useState(0); - const [availableWidth, setAvailableWidth] = useState(0); - - const useHorizontalLabelSelector = availableWidth > labelSelectorRequiredWidth; - - const controlsContainer = useRef(null); - - useResizeObserver({ - ref: controlsContainer, - onResize: () => { - const element = controlsContainer.current; - if (element) { - setAvailableWidth(element.clientWidth); - } - }, - }); - - useEffect(() => { - const { fontSize } = theme.typography; - const text = options.map((option) => option.label || option.value || '').join(' '); - const textWidth = measureText(text, fontSize).width; - const additionalWidthPerItem = 32; - setLabelSelectorRequiredWidth(textWidth + additionalWidthPerItem * options.length); - }, [options, theme]); - - return ( -
- {useHorizontalLabelSelector ? ( - - ) : ( - onChange(selected.value)} className={styles.select} />; } function getStyles(theme: GrafanaTheme2) { diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index 698207e53e5..7fb329c89b9 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -16,11 +16,11 @@ import { Box, Icon, LinkButton, Stack, Tab, TabsBar, ToolbarButton, Tooltip, use import { getExploreUrl } from '../../core/utils/explore'; -import { buildBreakdownActionScene } from './ActionTabs/BreakdownScene'; import { buildMetricOverviewScene } from './ActionTabs/MetricOverviewScene'; import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types'; +import { buildLabelBreakdownActionScene } from './Breakdown/LabelBreakdownScene'; import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; import { ShareTrailButton } from './ShareTrailButton'; import { useBookmarkState } from './TrailStore/useBookmarkState'; @@ -110,7 +110,7 @@ export class MetricScene extends SceneObjectBase { const actionViewsDefinitions: ActionViewDefinition[] = [ { displayName: 'Overview', value: 'overview', getScene: buildMetricOverviewScene }, - { displayName: 'Breakdown', value: 'breakdown', getScene: buildBreakdownActionScene }, + { displayName: 'Breakdown', value: 'breakdown', getScene: buildLabelBreakdownActionScene }, { displayName: 'Related metrics', value: 'related', diff --git a/public/app/features/trails/interactions.ts b/public/app/features/trails/interactions.ts index b1903721da8..c9bb69fca15 100644 --- a/public/app/features/trails/interactions.ts +++ b/public/app/features/trails/interactions.ts @@ -1,7 +1,7 @@ import { AdHocVariableFilter } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { BreakdownLayoutType } from './ActionTabs/types'; +import { BreakdownLayoutType } from './Breakdown/types'; import { TrailStepType } from './DataTrailsHistory'; import { ActionViewType } from './shared'; @@ -110,6 +110,7 @@ type Interactions = { | 'close' ) }; + wasm_not_supported: {}, }; const PREFIX = 'grafana_explore_metrics_'; diff --git a/public/app/features/trails/services/levels.test.ts b/public/app/features/trails/services/levels.test.ts new file mode 100644 index 00000000000..7284601ea77 --- /dev/null +++ b/public/app/features/trails/services/levels.test.ts @@ -0,0 +1,25 @@ +import { FieldType, toDataFrame } from '@grafana/data'; + +import { getLabelValueFromDataFrame } from './levels'; + +describe('getLabelValueFromDataFrame', () => { + it('returns correct label value from data frame', () => { + expect( + getLabelValueFromDataFrame( + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [0] }, + { + name: 'Value', + type: FieldType.number, + values: [1], + labels: { + detected_level: 'warn', + }, + }, + ], + }) + ) + ).toEqual('warn'); + }); +}); diff --git a/public/app/features/trails/services/levels.ts b/public/app/features/trails/services/levels.ts new file mode 100644 index 00000000000..d9605bc0dd4 --- /dev/null +++ b/public/app/features/trails/services/levels.ts @@ -0,0 +1,16 @@ +import { DataFrame } from '@grafana/data'; + +export function getLabelValueFromDataFrame(frame: DataFrame) { + const labels = frame.fields[1]?.labels; + + if (!labels) { + return null; + } + + const keys = Object.keys(labels); + if (keys.length === 0) { + return null; + } + + return labels[keys[0]]; +} diff --git a/public/app/features/trails/services/search.ts b/public/app/features/trails/services/search.ts new file mode 100644 index 00000000000..a893f7c7bd1 --- /dev/null +++ b/public/app/features/trails/services/search.ts @@ -0,0 +1,45 @@ +import uFuzzy from '@leeoniya/ufuzzy'; +import { debounce as debounceLodash } from 'lodash'; + +const uf = new uFuzzy({ + intraMode: 1, + intraIns: 1, + intraSub: 1, + intraTrn: 1, + intraDel: 1, +}); + +export function fuzzySearch(haystack: string[], query: string, callback: (data: string[][]) => void) { + const [idxs, info, order] = uf.search(haystack, query, 0, 1e5); + + let haystackOrder: string[] = []; + let matchesSet: Set = new Set(); + if (idxs && order) { + /** + * get the fuzzy matches for highlighting + * @param part + * @param matched + */ + const mark = (part: string, matched: boolean) => { + if (matched) { + matchesSet.add(part); + } + }; + + // Iterate to create the order of needles(queries) and the matches + for (let i = 0; i < order.length; i++) { + let infoIdx = order[i]; + + /** Evaluate the match, get the matches for highlighting */ + uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); + /** Get the order */ + haystackOrder.push(haystack[info.idx[infoIdx]]); + } + + callback([haystackOrder, [...matchesSet]]); + } else if (!query) { + callback([]); + } +} + +export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300); diff --git a/public/app/features/trails/services/store.ts b/public/app/features/trails/services/store.ts new file mode 100644 index 00000000000..94f9fb7fd5d --- /dev/null +++ b/public/app/features/trails/services/store.ts @@ -0,0 +1,9 @@ +import { TRAIL_BREAKDOWN_VIEW_KEY } from '../shared'; + +export function getVewByPreference() { + return localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY) ?? 'grid'; +} + +export function setVewByPreference(value?: string) { + return localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, value ?? 'grid'); +} diff --git a/public/app/features/trails/services/variables.ts b/public/app/features/trails/services/variables.ts new file mode 100644 index 00000000000..e9e36248c2c --- /dev/null +++ b/public/app/features/trails/services/variables.ts @@ -0,0 +1 @@ +export const ALL_VARIABLE_VALUE = '$__all'; diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 8399b6ffb36..ed23c24cfbe 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -2,7 +2,7 @@ import { BusEventWithPayload } from '@grafana/data'; import { ConstantVariable, SceneObject } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; -export type ActionViewType = 'overview' | 'breakdown' | 'logs' | 'related'; +export type ActionViewType = 'overview' | 'breakdown' | 'label-breakdown' | 'logs' | 'related'; export interface ActionViewDefinition { displayName: string; value: ActionViewType; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d77f5848760..71961f341b5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -119,7 +119,7 @@ "parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters." }, "used-by_one": "Used by {{ count }} notification policy", - "used-by_other": "Used by {{ count }} notification policies" + "used-by_other": "Used by {{ count }} notification policy" }, "contactPointFilter": { "label": "Contact point" @@ -169,7 +169,7 @@ "inherited": "Inherited", "mute-time": "Muted when", "n-instances_one": "instance", - "n-instances_other": "instances", + "n-instances_other": "instance", "timingOptions": { "groupInterval": { "description": "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent.", @@ -185,7 +185,7 @@ } } }, - "n-more-policies_one": "{{count}} additional policy", + "n-more-policies_one": "{{count}} additional policies", "n-more-policies_other": "{{count}} additional policies", "new-child": "New child policy", "new-policy": "Add new policy", @@ -291,15 +291,15 @@ }, "counts": { "alertRule_one": "{{count}} alert rule", - "alertRule_other": "{{count}} alert rules", + "alertRule_other": "{{count}} alert rule", "dashboard_one": "{{count}} dashboard", - "dashboard_other": "{{count}} dashboards", + "dashboard_other": "{{count}} dashboard", "folder_one": "{{count}} folder", - "folder_other": "{{count}} folders", + "folder_other": "{{count}} folder", "libraryPanel_one": "{{count}} library panel", - "libraryPanel_other": "{{count}} library panels", + "libraryPanel_other": "{{count}} library panel", "total_one": "{{count}} item", - "total_other": "{{count}} items" + "total_other": "{{count}} item" }, "dashboards-tree": { "collapse-folder-button": "Collapse folder {{title}}", @@ -935,6 +935,11 @@ } }, "explore-metrics": { + "breakdown": { + "clearFilter": "Clear filter", + "labelSelect": "Select", + "noMatchingValue": "No values found matching; {{filter}}" + }, "viewBy": "View by" }, "export": { @@ -2166,16 +2171,16 @@ "confirm-text": "Delete", "delete-button": "Delete", "delete-loading": "Deleting...", - "text_one": "This action will delete {{numberOfDashboards}} dashboard.", + "text_one": "This action will delete {{numberOfDashboards}} dashboards.", "text_other": "This action will delete {{numberOfDashboards}} dashboards.", "title": "Permanently Delete Dashboards" }, "restore-modal": { - "folder-picker-text_one": "Please choose a folder where your dashboard will be restored.", + "folder-picker-text_one": "Please choose a folder where your dashboards will be restored.", "folder-picker-text_other": "Please choose a folder where your dashboards will be restored.", "restore-button": "Restore", "restore-loading": "Restoring...", - "text_one": "This action will restore {{numberOfDashboards}} dashboard.", + "text_one": "This action will restore {{numberOfDashboards}} dashboards.", "text_other": "This action will restore {{numberOfDashboards}} dashboards.", "title": "Restore Dashboards" } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index d4dfb80e8c0..8f2e09e0cb2 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -119,7 +119,7 @@ "parse-mode-warning-title": "Ŧęľęģřäm męşşäģęş äřę ľįmįŧęđ ŧő 4096 ŮŦF-8 čĥäřäčŧęřş." }, "used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy", - "used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş" + "used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy" }, "contactPointFilter": { "label": "Cőʼnŧäčŧ pőįʼnŧ" @@ -169,7 +169,7 @@ "inherited": "Ĩʼnĥęřįŧęđ", "mute-time": "Mūŧęđ ŵĥęʼn", "n-instances_one": "įʼnşŧäʼnčę", - "n-instances_other": "įʼnşŧäʼnčęş", + "n-instances_other": "įʼnşŧäʼnčę", "timingOptions": { "groupInterval": { "description": "Ħőŵ ľőʼnģ ŧő ŵäįŧ þęƒőřę şęʼnđįʼnģ ä ʼnőŧįƒįčäŧįőʼn äþőūŧ ʼnęŵ äľęřŧş ŧĥäŧ äřę äđđęđ ŧő ä ģřőūp őƒ äľęřŧş ƒőř ŵĥįčĥ äʼn įʼnįŧįäľ ʼnőŧįƒįčäŧįőʼn ĥäş äľřęäđy þęęʼn şęʼnŧ.", @@ -185,7 +185,7 @@ } } }, - "n-more-policies_one": "{{count}} äđđįŧįőʼnäľ pőľįčy", + "n-more-policies_one": "{{count}} äđđįŧįőʼnäľ pőľįčįęş", "n-more-policies_other": "{{count}} äđđįŧįőʼnäľ pőľįčįęş", "new-child": "Ńęŵ čĥįľđ pőľįčy", "new-policy": "Åđđ ʼnęŵ pőľįčy", @@ -291,15 +291,15 @@ }, "counts": { "alertRule_one": "{{count}} äľęřŧ řūľę", - "alertRule_other": "{{count}} äľęřŧ řūľęş", + "alertRule_other": "{{count}} äľęřŧ řūľę", "dashboard_one": "{{count}} đäşĥþőäřđ", - "dashboard_other": "{{count}} đäşĥþőäřđş", + "dashboard_other": "{{count}} đäşĥþőäřđ", "folder_one": "{{count}} ƒőľđęř", - "folder_other": "{{count}} ƒőľđęřş", + "folder_other": "{{count}} ƒőľđęř", "libraryPanel_one": "{{count}} ľįþřäřy päʼnęľ", - "libraryPanel_other": "{{count}} ľįþřäřy päʼnęľş", + "libraryPanel_other": "{{count}} ľįþřäřy päʼnęľ", "total_one": "{{count}} įŧęm", - "total_other": "{{count}} įŧęmş" + "total_other": "{{count}} įŧęm" }, "dashboards-tree": { "collapse-folder-button": "Cőľľäpşę ƒőľđęř {{title}}", @@ -935,6 +935,11 @@ } }, "explore-metrics": { + "breakdown": { + "clearFilter": "Cľęäř ƒįľŧęř", + "labelSelect": "Ŝęľęčŧ", + "noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}" + }, "viewBy": "Vįęŵ þy" }, "export": { @@ -2166,16 +2171,16 @@ "confirm-text": "Đęľęŧę", "delete-button": "Đęľęŧę", "delete-loading": "Đęľęŧįʼnģ...", - "text_one": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđ.", + "text_one": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđş.", "text_other": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđş.", "title": "Pęřmäʼnęʼnŧľy Đęľęŧę Đäşĥþőäřđş" }, "restore-modal": { - "folder-picker-text_one": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđ ŵįľľ þę řęşŧőřęđ.", + "folder-picker-text_one": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđş ŵįľľ þę řęşŧőřęđ.", "folder-picker-text_other": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđş ŵįľľ þę řęşŧőřęđ.", "restore-button": "Ŗęşŧőřę", "restore-loading": "Ŗęşŧőřįʼnģ...", - "text_one": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđ.", + "text_one": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđş.", "text_other": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđş.", "title": "Ŗęşŧőřę Đäşĥþőäřđş" }