mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Refactor explore metrics breakdown scene (#93368)
* refactor breakdown scene * refactor BreakdownScene along with LayoutSwitcher * rename * don't pass default layout * better type handling * betterer * add @bsull/augurs * implement LabelBreakdownScene * integrate SortByScene in LabelBreakdownScene * move to new directory * introduce BreakdownSearchScene * integrate searchScene * cleaning * initialize @bsull/augurs * add interaction * use new breakdown scene * resolve merge conflicts * ugrade @bsull/augurs * update import * update css * update tooltip text * refine sorting * fix unit test * fix * implement outlier detector * support wasm * jest testing fix * localization fix * use unknown instead of any * update i18n * update betterer * fix locales * update test * fix tests maybe * prettier * chore: update jest config * chore: create mock for @bsull/augurs (#92156) chore: create mock for bsull/augurs @bsull/augurs assumes it will be running as an ESM, not a CommonJS module, so can't be loaded by Jest (specifically because it contains a reference to import.meta.url). This PR provides a mock implementation which gets tests passing again. Ideally we'd be able to load the actual @bsull/augurs module in tests so this is just a stopgap really, until a better solution appears. * fix unit tests * remove unused BreakdownScene.tsx * set outliers as undefined if an error occurs * Add labels * betterer * reset event implemented * fix controls positioning * remove sorting * fix type guard * more clean up * remove wasm support from webpack * betterer --------- Co-authored-by: Matias Chomicki <matyax@gmail.com> Co-authored-by: Ben Sully <ben.sully@grafana.com>
This commit is contained in:
parent
74b2d38e37
commit
19c7e1f376
@ -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 <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/trails/ActionTabs/BreakdownScene.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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": [
|
||||
|
@ -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<ByFrameRepeaterState> {
|
||||
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<ByFrameRepeater>) => {
|
||||
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 (
|
||||
<StatusWrapper {...{ blockingMessage, error, isLoading }}>
|
||||
<body.Component model={body} />
|
||||
</StatusWrapper>
|
||||
);
|
||||
};
|
||||
}
|
@ -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<string, string> = {};
|
||||
|
||||
export class BreakdownSearchScene extends SceneObjectBase<BreakdownSearchSceneState> {
|
||||
private cacheKey: string;
|
||||
|
||||
constructor(cacheKey: string) {
|
||||
super({
|
||||
filter: recentFilters[cacheKey] ?? '',
|
||||
});
|
||||
this.cacheKey = cacheKey;
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<BreakdownSearchScene>) => {
|
||||
const { filter } = model.useState();
|
||||
return (
|
||||
<SearchInput
|
||||
value={filter}
|
||||
onChange={model.onValueFilterChange}
|
||||
onClear={model.clearValueFilter}
|
||||
placeholder="Search for value"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
public onValueFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
189
public/app/features/trails/Breakdown/ByFrameRepeater.tsx
Normal file
189
public/app/features/trails/Breakdown/ByFrameRepeater.tsx
Normal file
@ -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<ByFrameRepeaterState> {
|
||||
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<SceneByFrameRepeater>) => {
|
||||
const { body } = model.useState();
|
||||
return <body.Component model={body} />;
|
||||
};
|
||||
}
|
||||
|
||||
function buildNoResultsScene(filter: string, clearFilter: () => void) {
|
||||
return new SceneFlexLayout({
|
||||
direction: 'row',
|
||||
children: [
|
||||
new SceneFlexItem({
|
||||
body: new SceneReactObject({
|
||||
reactNode: (
|
||||
<div className={styles.alertContainer}>
|
||||
<Alert title="" severity="info" className={styles.noResultsAlert}>
|
||||
<Trans i18nKey="explore-metrics.breakdown.noMatchingValue">
|
||||
No values found matching; {{ filter }}
|
||||
</Trans>
|
||||
<Button className={styles.clearButton} onClick={clearFilter}>
|
||||
<Trans i18nKey="explore-metrics.breakdown.clearFilter">Clear filter</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
@ -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<SelectableValue<string>>;
|
||||
value?: string;
|
||||
loading?: boolean;
|
||||
@ -52,16 +57,17 @@ export interface BreakdownSceneState extends SceneObjectState {
|
||||
blockingMessage?: string;
|
||||
}
|
||||
|
||||
export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
|
||||
export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneState> {
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: [VAR_FILTERS],
|
||||
onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this),
|
||||
});
|
||||
|
||||
constructor(state: Partial<BreakdownSceneState>) {
|
||||
constructor(state: Partial<LabelBreakdownSceneState>) {
|
||||
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<BreakdownSceneState> {
|
||||
}
|
||||
});
|
||||
|
||||
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<BreakdownSceneState> {
|
||||
|
||||
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<BreakdownSceneState> {
|
||||
private updateBody(variable: QueryVariable) {
|
||||
const options = getLabelOptions(this, variable);
|
||||
|
||||
const stateUpdate: Partial<BreakdownSceneState> = {
|
||||
const stateUpdate: Partial<LabelBreakdownSceneState> = {
|
||||
loading: variable.state.loading,
|
||||
value: String(variable.state.value),
|
||||
labels: options,
|
||||
@ -181,7 +194,7 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
|
||||
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<BreakdownSceneState> {
|
||||
variable.changeValueTo(value);
|
||||
};
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<BreakdownScene>) => {
|
||||
const { labels, body, loading, value, blockingMessage } = model.useState();
|
||||
public static Component = ({ model }: SceneComponentProps<LabelBreakdownScene>) => {
|
||||
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<BreakdownSceneState> {
|
||||
<StatusWrapper {...{ isLoading: loading, blockingMessage }}>
|
||||
<div className={styles.controls}>
|
||||
{!loading && labels.length && (
|
||||
<div className={styles.controlsLeft}>
|
||||
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
|
||||
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
|
||||
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{value !== ALL_VARIABLE_VALUE && (
|
||||
<Field label="Search" className={styles.searchField}>
|
||||
<search.Component model={search} />
|
||||
</Field>
|
||||
)}
|
||||
{body instanceof LayoutSwitcher && (
|
||||
<div className={styles.controlsRight}>
|
||||
<Field label="View">
|
||||
<body.Selector model={body} />
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>{body && <body.Component model={body} />}</div>
|
||||
@ -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: <LoadingPlaceholder text="Loading..." />,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
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<SelectLabelActionState> {
|
||||
public onClick = () => {
|
||||
const label = this.state.labelName;
|
||||
@ -446,14 +471,14 @@ export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> {
|
||||
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" fill="solid" onClick={model.onClick}>
|
||||
Select
|
||||
<Trans i18nKey="explore-metrics.breakdown.labelSelect">Select</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getBreakdownSceneFor(model: SceneObject): BreakdownScene {
|
||||
if (model instanceof BreakdownScene) {
|
||||
function getBreakdownSceneFor(model: SceneObject): LabelBreakdownScene {
|
||||
if (model instanceof LabelBreakdownScene) {
|
||||
return model;
|
||||
}
|
||||
|
@ -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<LayoutSwitcherState> impleme
|
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['breakdownLayout'] });
|
||||
|
||||
public constructor(state: MakeOptional<LayoutSwitcherState, 'activeBreakdownLayout'>) {
|
||||
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<LayoutSwitcherState> impleme
|
||||
const { activeBreakdownLayout, breakdownLayoutOptions } = model.useState();
|
||||
|
||||
return (
|
||||
<Field label="View">
|
||||
<RadioButtonGroup
|
||||
options={breakdownLayoutOptions}
|
||||
value={activeBreakdownLayout}
|
||||
onChange={model.onLayoutChange}
|
||||
/>
|
||||
</Field>
|
||||
<RadioButtonGroup
|
||||
options={breakdownLayoutOptions}
|
||||
value={activeBreakdownLayout}
|
||||
onChange={model.onLayoutChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -66,7 +65,7 @@ export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> impleme
|
||||
}
|
||||
|
||||
reportExploreMetrics('breakdown_layout_changed', { layout: active });
|
||||
localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, active);
|
||||
setVewByPreference(active);
|
||||
this.setState({ activeBreakdownLayout: active });
|
||||
this.state.onBreakdownLayoutChange(active);
|
||||
};
|
29
public/app/features/trails/Breakdown/SearchInput.tsx
Normal file
29
public/app/features/trails/Breakdown/SearchInput.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { HTMLProps } from 'react';
|
||||
|
||||
import { Icon, Input } from '@grafana/ui';
|
||||
|
||||
interface Props extends Omit<HTMLProps<HTMLInputElement>, 'width'> {
|
||||
onClear(): void;
|
||||
}
|
||||
|
||||
export const SearchInput = ({ value, onChange, placeholder, onClear, ...rest }: Props) => {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
suffix={
|
||||
value ? <Icon onClick={onClear} title={'Clear search'} name="times" className={styles.clearIcon} /> : undefined
|
||||
}
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
clearIcon: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
};
|
21
public/app/features/trails/Breakdown/types.test.ts
Normal file
21
public/app/features/trails/Breakdown/types.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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) => {
|
@ -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<SelectableValue<string>>;
|
||||
@ -13,42 +11,8 @@ type Props = {
|
||||
|
||||
export function BreakdownLabelSelector({ options, value, onChange }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
|
||||
const [labelSelectorRequiredWidth, setLabelSelectorRequiredWidth] = useState<number>(0);
|
||||
const [availableWidth, setAvailableWidth] = useState<number>(0);
|
||||
|
||||
const useHorizontalLabelSelector = availableWidth > labelSelectorRequiredWidth;
|
||||
|
||||
const controlsContainer = useRef<HTMLDivElement>(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 (
|
||||
<div ref={controlsContainer}>
|
||||
{useHorizontalLabelSelector ? (
|
||||
<RadioButtonGroup {...{ options, value, onChange }} />
|
||||
) : (
|
||||
<Select {...{ options, value }} onChange={(selected) => onChange(selected.value)} className={styles.select} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <Select {...{ options, value }} onChange={(selected) => onChange(selected.value)} className={styles.select} />;
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
|
@ -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<MetricSceneState> {
|
||||
|
||||
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',
|
||||
|
@ -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_';
|
||||
|
25
public/app/features/trails/services/levels.test.ts
Normal file
25
public/app/features/trails/services/levels.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
16
public/app/features/trails/services/levels.ts
Normal file
16
public/app/features/trails/services/levels.ts
Normal file
@ -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]];
|
||||
}
|
45
public/app/features/trails/services/search.ts
Normal file
45
public/app/features/trails/services/search.ts
Normal file
@ -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<string> = 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);
|
9
public/app/features/trails/services/store.ts
Normal file
9
public/app/features/trails/services/store.ts
Normal file
@ -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');
|
||||
}
|
1
public/app/features/trails/services/variables.ts
Normal file
1
public/app/features/trails/services/variables.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ALL_VARIABLE_VALUE = '$__all';
|
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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": "Ŗęşŧőřę Đäşĥþőäřđş"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user