Explore Metrics: Implement grouping with metric prefixes (#89481)

* add groop as a local dependency

* update layout

* nested layout with panels

* fix the height of the rows

* copy groop library into grafana/grafana

* Don't create a new scene everytime metrics refreshed

* Add display option dropdown

* handle different layout options in buildLayout

* add select component props

* unify scene body creation

* handle other display cases in refreshMetricNames

* set a new body when display format is different

* handle nestedScene population

* show nested groups

* handle panel display

* add tabs view

* populate tabs view

* show selected tab group

* show display options before metric search

* populate prefix filter layout

* only switch layout for nested-rows display option

* Update public/app/features/trails/groop/parser.ts

Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com>

* Update public/app/features/trails/groop/parser.ts

Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com>

* Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx

Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com>

* Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx

Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com>

* Remove tab view

* generate groups async

* Remove unnecessary parts

* Refactor

* implement urlSync

* update keys

* introduce interaction

* ui updates

* chore: revert some auto formatting to clarify comments

* chore: revert some auto formatting to clarify comments

* rename

* add tooltip

* add styles

* update unit tests

* make i18n-extract

* update unit test

---------

Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com>
Co-authored-by: Darren Janeczek <darren.janeczek@grafana.com>
This commit is contained in:
ismail simsek
2024-07-23 18:23:28 +02:00
committed by GitHub
parent d2b78931a1
commit 87b2494872
9 changed files with 844 additions and 42 deletions

View File

@@ -1,9 +1,8 @@
import { css } from '@emotion/css';
import { debounce, isEqual } from 'lodash';
import { useReducer } from 'react';
import * as React from 'react';
import { SyntheticEvent, useReducer } from 'react';
import { GrafanaTheme2, RawTimeRange } from '@grafana/data';
import { GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import {
AdHocFiltersVariable,
@@ -12,21 +11,28 @@ import {
SceneCSSGridItem,
SceneCSSGridLayout,
SceneFlexItem,
SceneFlexLayout,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectStateChangedEvent,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneObjectWithUrlSync,
SceneTimeRange,
SceneVariable,
SceneVariableSet,
VariableDependencyConfig,
} from '@grafana/scenes';
import { InlineSwitch, Field, Alert, Icon, useStyles2, Tooltip, Input } from '@grafana/ui';
import { Alert, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DataTrail } from '../DataTrail';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
import { Node, Parser } from '../groop/parser';
import { getMetricDescription } from '../helpers/MetricDatasourceHelper';
import { reportExploreMetrics } from '../interactions';
import {
@@ -54,7 +60,9 @@ interface MetricPanel {
}
export interface MetricSelectSceneState extends SceneObjectState {
body: SceneCSSGridLayout;
body: SceneFlexLayout | SceneCSSGridLayout;
rootGroup?: Node;
metricPrefix?: string;
showPreviews?: boolean;
metricNames?: string[];
metricNamesLoading?: boolean;
@@ -64,16 +72,23 @@ export interface MetricSelectSceneState extends SceneObjectState {
const ROW_PREVIEW_HEIGHT = '175px';
const ROW_CARD_HEIGHT = '64px';
const METRIC_PREFIX_ALL = 'all';
const MAX_METRIC_NAMES = 20000;
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
const viewByTooltip =
'View by the metric prefix. A metric prefix is a single word at the beginning of the metric name, relevant to the domain the metric belongs to.';
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> implements SceneObjectWithUrlSync {
private previewCache: Record<string, MetricPanel> = {};
private ignoreNextUpdate = false;
private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);
constructor(state: Partial<MetricSelectSceneState>) {
super({
showPreviews: true,
$variables: state.$variables,
metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL,
body:
state.body ??
new SceneCSSGridLayout({
@@ -82,13 +97,13 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
autoRows: ROW_PREVIEW_HEIGHT,
isLazy: true,
}),
showPreviews: true,
...state,
});
this.addActivationHandler(this._onActivate.bind(this));
}
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metricPrefix'] });
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_DATASOURCE, VAR_FILTERS],
onReferencedVariableValueChanged: (variable: SceneVariable) => {
@@ -97,6 +112,18 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
},
});
getUrlState() {
return { metricPrefix: this.state.metricPrefix };
}
updateFromUrl(values: SceneObjectUrlValues) {
if (typeof values.metricPrefix === 'string') {
if (this.state.metricPrefix !== values.metricPrefix) {
this.setState({ metricPrefix: values.metricPrefix });
}
}
}
private _onActivate() {
if (this.state.body.state.children.length === 0) {
this.buildLayout();
@@ -159,8 +186,6 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
this._debounceRefreshMetricNames();
}
private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);
private async _refreshMetricNames() {
const trail = getTrailFor(this);
const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state;
@@ -199,7 +224,17 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
`Add search terms or label filters to narrow down the number of metric names returned.`
: undefined;
this.setState({ metricNames, metricNamesLoading: false, metricNamesWarning, metricNamesError: response.error });
let bodyLayout = this.state.body;
const rootGroupNode = await this.generateGroups(metricNames);
this.setState({
metricNames,
rootGroup: rootGroupNode,
body: bodyLayout,
metricNamesLoading: false,
metricNamesWarning,
metricNamesError: response.error,
});
} catch (err: unknown) {
let error = 'Unknown error';
if (isFetchError(err)) {
@@ -214,19 +249,16 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
}
}
private sortedPreviewMetrics() {
return Object.values(this.previewCache).sort((a, b) => {
if (a.isEmpty && b.isEmpty) {
return a.index - b.index;
}
if (a.isEmpty) {
return 1;
}
if (b.isEmpty) {
return -1;
}
return a.index - b.index;
});
private async generateGroups(metricNames: string[] = []) {
const groopParser = new Parser();
groopParser.config = {
...groopParser.config,
maxDepth: 2,
minGroupSize: 2,
miscGroupKey: 'misc',
};
const { root: rootGroupNode } = groopParser.parse(metricNames);
return rootGroupNode;
}
private onMetricNamesChanged() {
@@ -286,32 +318,67 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
return;
}
const children: SceneFlexItem[] = [];
if (!this.state.rootGroup) {
const rootGroupNode = await this.generateGroups(this.state.metricNames);
this.setState({ rootGroup: rootGroupNode });
}
const children = await this.populateFilterableViewLayout();
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
this.state.body.setState({ children, autoRows: rowTemplate });
}
private async populateFilterableViewLayout() {
const trail = getTrailFor(this);
const metricsList = this.sortedPreviewMetrics();
// Get the current filters to determine the count of them
// Which is required for `getPreviewPanelFor`
const filters = getFilters(this);
let rootGroupNode = this.state.rootGroup;
if (!rootGroupNode) {
rootGroupNode = await this.generateGroups(this.state.metricNames);
this.setState({ rootGroup: rootGroupNode });
}
const children: SceneFlexItem[] = [];
for (const [groupKey, groupNode] of rootGroupNode.groups) {
if (this.state.metricPrefix !== METRIC_PREFIX_ALL && this.state.metricPrefix !== groupKey) {
continue;
}
for (const [_, value] of groupNode.groups) {
const panels = await this.populatePanels(trail, filters, value.values);
children.push(...panels);
}
const morePanelsMaybe = await this.populatePanels(trail, filters, groupNode.values);
children.push(...morePanelsMaybe);
}
return children;
}
private async populatePanels(trail: DataTrail, filters: ReturnType<typeof getFilters>, values: string[]) {
const currentFilterCount = filters?.length || 0;
for (let index = 0; index < metricsList.length; index++) {
const metric = metricsList[index];
const metadata = await trail.getMetricMetadata(metric.name);
const previewPanelLayoutItems: SceneFlexItem[] = [];
for (let index = 0; index < values.length; index++) {
const metricName = values[index];
const metric: MetricPanel = this.previewCache[metricName] ?? { name: metricName, index, loaded: false };
const metadata = await trail.getMetricMetadata(metricName);
const description = getMetricDescription(metadata);
if (this.state.showPreviews) {
if (metric.itemRef && metric.isPanel) {
children.push(metric.itemRef.resolve());
previewPanelLayoutItems.push(metric.itemRef.resolve());
continue;
}
const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description);
metric.itemRef = panel.getRef();
metric.isPanel = true;
children.push(panel);
previewPanelLayoutItems.push(panel);
} else {
const panel = new SceneCSSGridItem({
$variables: new SceneVariableSet({
@@ -321,13 +388,11 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
});
metric.itemRef = panel.getRef();
metric.isPanel = false;
children.push(panel);
previewPanelLayoutItems.push(panel);
}
}
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
this.state.body.setState({ children, autoRows: rowTemplate });
return previewPanelLayoutItems;
}
public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => {
@@ -336,25 +401,52 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
metricPanel.isEmpty = isEmpty;
metricPanel.loaded = isLoaded;
this.previewCache[metric] = metricPanel;
this.buildLayout();
if (this.state.metricPrefix === 'All') {
this.buildLayout();
}
}
};
public onSearchQueryChange = (evt: React.SyntheticEvent<HTMLInputElement>) => {
public onSearchQueryChange = (evt: SyntheticEvent<HTMLInputElement>) => {
const metricSearch = evt.currentTarget.value;
const trail = getTrailFor(this);
// Update the variable
trail.setState({ metricSearch });
};
public onPrefixFilterChange = (val: SelectableValue) => {
this.setState({ metricPrefix: val.value });
this.buildLayout();
};
public reportPrefixFilterInteraction = (isMenuOpen: boolean) => {
const trail = getTrailFor(this);
const { steps, currentStep } = trail.state.history.state;
const previousMetric = steps[currentStep]?.trailState.metric;
const isRelatedMetricSelector = previousMetric !== undefined;
reportExploreMetrics('prefix_filter_clicked', {
from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list',
action: isMenuOpen ? 'open' : 'close',
});
};
public onTogglePreviews = () => {
this.setState({ showPreviews: !this.state.showPreviews });
this.buildLayout();
};
public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => {
const { showPreviews, body, metricNames, metricNamesError, metricNamesLoading, metricNamesWarning } =
model.useState();
const {
showPreviews,
body,
metricNames,
metricNamesError,
metricNamesLoading,
metricNamesWarning,
rootGroup,
metricPrefix,
} = model.useState();
const { children } = body.useState();
const trail = getTrailFor(model);
const styles = useStyles2(getStyles);
@@ -399,6 +491,29 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
suffix={metricNamesWarningIcon}
/>
</Field>
<Field
label={
<div className={styles.displayOptionTooltip}>
<Trans i18nKey="explore-metrics.viewBy">View by</Trans>
<IconButton name={'info-circle'} size="sm" variant={'secondary'} tooltip={viewByTooltip} />
</div>
}
className={styles.displayOption}
>
<Select
value={metricPrefix}
onChange={model.onPrefixFilterChange}
onOpenMenu={() => model.reportPrefixFilterInteraction(true)}
onCloseMenu={() => model.reportPrefixFilterInteraction(false)}
options={[
{
label: 'All metric names',
value: METRIC_PREFIX_ALL,
},
...Array.from(rootGroup?.groups.keys() ?? []).map((g) => ({ label: `${g}_`, value: g })),
]}
/>
</Field>
<InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} />
</div>
{metricNamesError && (
@@ -418,7 +533,8 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
</Alert>
)}
<StatusWrapper {...{ isLoading, blockingMessage }}>
<body.Component model={body} />
{body instanceof SceneFlexLayout && <body.Component model={body} />}
{body instanceof SceneCSSGridLayout && <body.Component model={body} />}
</StatusWrapper>
</div>
);
@@ -439,7 +555,6 @@ function getStyles(theme: GrafanaTheme2) {
container: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
headingWrapper: css({
marginBottom: theme.spacing(0.5),
@@ -455,6 +570,18 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1,
marginBottom: 0,
}),
metricTabGroup: css({
marginBottom: theme.spacing(2),
}),
displayOption: css({
flexGrow: 0,
marginBottom: 0,
minWidth: '184px',
}),
displayOptionTooltip: css({
display: 'flex',
gap: theme.spacing(1),
}),
warningIcon: css({
color: theme.colors.warning.main,
}),

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from '@jest/globals';
import { readFileSync } from 'fs';
import { join } from 'path';
import { collectValues, lookup, lookupNode } from './lookup';
import { Parser } from './parser';
const metrics = readFileSync(join(__dirname, 'testdata', 'metrics.txt'), 'utf8').split('\n');
describe('lookup', () => {
it('should find exact group matches', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const group1 = lookup(grouping.root, 'agent');
expect(group1).not.toBeNull();
expect(group1!.descendants).toBe(41);
expect(group1!.groups.size).toBe(1);
expect(group1!.values.length).toBe(0);
const allValues = collectValues(group1!);
expect(allValues.length).toBe(41);
});
it('should find groups and values that contain all of the prefix', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const group1 = lookup(grouping.root, 'age');
expect(group1).not.toBeNull();
expect(group1!.descendants).toBe(41);
expect(group1!.groups.size).toBe(1);
expect(group1!.values.length).toBe(0);
const allValues = collectValues(group1!);
expect(allValues.length).toBe(41);
});
it('should find nested groups and values that match', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const matches = lookup(grouping.root, 'agent_metrics_c');
expect(matches).not.toBeNull();
expect(matches!.descendants).toBe(9);
const allValues = collectValues(matches!);
expect(allValues.length).toBe(9);
});
});
describe('lookupNode', () => {
it('should find matching groups by prefix', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const group1 = lookupNode(grouping.root, 'agent');
expect(group1).not.toBeNull();
expect(group1!.descendants).toBe(41);
expect(group1!.groups.size).toBe(3);
expect(group1!.values.length).toBe(8);
const group2 = lookupNode(grouping.root, 'agent_metrics_ha_configs');
expect(group2).not.toBeNull();
expect(group2!.descendants).toBe(6);
expect(group2!.values.length).toBe(6);
expect(group2!.values[0]).toBe('agent_metrics_ha_configs_created');
expect(group2!.values[1]).toBe('agent_metrics_ha_configs_created_total');
expect(group2!.values[2]).toBe('agent_metrics_ha_configs_deleted');
expect(group2!.values[3]).toBe('agent_metrics_ha_configs_deleted_total');
expect(group2!.values[4]).toBe('agent_metrics_ha_configs_updated');
expect(group2!.values[5]).toBe('agent_metrics_ha_configs_updated_total');
});
it('should be able to collect all values', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const group1 = lookupNode(grouping.root, 'agent');
expect(group1).not.toBeNull();
const allValues = collectValues(group1!);
expect(allValues.length).toBe(41);
// check a sample of values
expect(allValues).toContain('agent_config_hash');
expect(allValues).toContain('agent_metrics_active_instances');
expect(allValues).toContain('agent_metrics_ha_configs_created_total');
expect(allValues).toContain('agent_tcp_connections_limit');
expect(allValues).toContain('agent_wal_storage_created_series_total');
});
});

View File

@@ -0,0 +1,75 @@
export { lookupNode, lookup, collectValues };
import { prefixDelimited } from './parser';
interface Node {
groups: Map<string, Node>;
values: string[];
descendants: number;
}
// lookup finds the node matching the prefix.
// If no match is found, it returns null.
function lookupNode(node: Node, prefix: string): Node | null {
return _lookupNode(node, 0, prefix);
}
// _lookupNode is the recursive implementation of lookup.
function _lookupNode(node: Node, level: number, prefix: string): Node | null {
const thisPrefix = prefixDelimited(prefix, level);
for (const [k, group] of node.groups.entries()) {
if (k === prefix) {
// perfect match
return group;
}
if (k.startsWith(thisPrefix)) {
return _lookupNode(group, level + 1, prefix);
}
}
return null;
}
function lookup(node: Node, prefix: string): Node | null {
const groups = new Map<string, Node>();
const values: string[] = [];
let descendants = 0;
for (const [nodePrefix, group] of node.groups.entries()) {
if (nodePrefix.startsWith(prefix)) {
groups.set(nodePrefix, group);
descendants += group.descendants;
continue;
}
if (prefix.startsWith(nodePrefix)) {
const subGroup = lookup(group, prefix);
if (subGroup) {
groups.set(nodePrefix, subGroup);
descendants += subGroup.descendants;
}
}
}
for (const v of node.values) {
if (v.startsWith(prefix)) {
values.push(v);
descendants += 1;
}
}
if (groups.size === 0 && values.length === 0) {
// nothing partially matching - so just return null
return null;
}
return { groups, values, descendants };
}
// collectValues returns all values from the node and its descendants.
function collectValues(node: Node): string[] {
const values: string[] = []; // Specify the type of the 'values' array
function collectFrom(currentNode: Node): void {
values.push(...currentNode.values);
for (const groupNode of currentNode.groups.values()) {
collectFrom(groupNode);
}
}
collectFrom(node);
return values;
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from '@jest/globals';
import { readFileSync } from 'fs';
import { join } from 'path';
import { lookupNode } from './lookup';
import { Parser, prefixDelimited } from './parser';
describe('Parser', () => {
const metrics = readFileSync(join(__dirname, 'testdata', 'metrics.txt'), 'utf8').split('\n');
it('should parse strings and build a tree', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
expect(grouping.root.groups.has('agent')).toBe(true);
const agentGroup = grouping.root.groups.get('agent')!;
expect(agentGroup.descendants).toBe(41);
expect(agentGroup.groups.size).toBe(3);
expect(agentGroup.groups.has('agent_config')).toBe(true);
expect(agentGroup.groups.has('agent_metrics')).toBe(true);
expect(agentGroup.groups.has('agent_wal')).toBe(true);
// get all keys from agentGroup.groups
const groupKeys = Array.from(agentGroup.groups.keys());
expect(groupKeys.length).toBe(3);
expect(groupKeys[0]).toBe('agent_config');
expect(groupKeys[1]).toBe('agent_metrics');
expect(groupKeys[2]).toBe('agent_wal');
// check the values
expect(agentGroup.values.length).toBe(8);
expect(agentGroup.values[0]).toBe('agent');
expect(agentGroup.values[1]).toBe('agent_build_info');
expect(agentGroup.values[2]).toBe('agent_inflight_requests');
expect(agentGroup.values[3]).toBe('agent_request_duration_seconds');
expect(agentGroup.values[4]).toBe('agent_request_message_bytes');
expect(agentGroup.values[5]).toBe('agent_response_message_bytes');
expect(agentGroup.values[6]).toBe('agent_tcp_connections');
expect(agentGroup.values[7]).toBe('agent_tcp_connections_limit');
});
it('should put metrics that match the name of the group in that group', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
const grouping = parser.parse(metrics);
const agentGroup = lookupNode(grouping.root, 'agent');
expect(agentGroup).not.toBeNull();
expect(agentGroup!.values.length).toBe(8);
expect(agentGroup!.values[0]).toBe('agent');
});
it('should respect maxDepth', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
parser.config.maxDepth = 1;
const grouping = parser.parse(metrics);
const agentGroup = lookupNode(grouping.root, 'agent');
expect(agentGroup).not.toBeNull();
expect(agentGroup!.values.length).toBe(41);
});
it('should support misc for left-over values', () => {
const parser = new Parser();
parser.config.idealMaxGroupSize = 5;
parser.config.maxDepth = 1;
parser.config.miscGroupKey = 'misc';
const grouping = parser.parse(metrics);
expect(grouping.root.descendants).toBe(263);
expect(grouping.root.values.length).toBe(0);
const miscGroup = lookupNode(grouping.root, 'misc');
expect(miscGroup).not.toBeNull();
expect(miscGroup!.values.length).toBe(17);
expect(miscGroup?.descendants).toBe(17);
});
});
describe('prefixDelimited', () => {
it('should return the prefix of a string', () => {
expect(prefixDelimited('agent_metrics_ha_configs_created', 0)).toBe('agent');
expect(prefixDelimited('agent_metrics_ha_configs_created', 1)).toBe('agent_metrics');
expect(prefixDelimited('agent_metrics_ha_configs_created', 2)).toBe('agent_metrics_ha');
expect(prefixDelimited('agent_metrics_ha_configs_created', 3)).toBe('agent_metrics_ha_configs');
expect(prefixDelimited('agent_metrics_ha_configs_created', 4)).toBe('agent_metrics_ha_configs_created');
expect(prefixDelimited('agent_metrics_ha_configs_created', 5)).toBe('agent_metrics_ha_configs_created');
});
});

View File

@@ -0,0 +1,138 @@
export { Parser, prefixDelimited };
interface Config {
minGroupSize: number;
idealMaxGroupSize: number;
maxDepth: number;
miscGroupKey?: string;
}
interface Grouping {
root: Node;
}
class Parser {
public config: Config;
constructor() {
this.config = {
minGroupSize: 3,
idealMaxGroupSize: 30,
maxDepth: 100,
};
}
parse(values: string[]): Grouping {
if (this.config.maxDepth <= 0) {
throw new Error('Max depth must be greater than 0');
}
if (this.config.minGroupSize < 1) {
throw new Error('Min group size must be greater than 0');
}
if (this.config.idealMaxGroupSize < this.config.minGroupSize) {
throw new Error('Max group size must be greater than min group size');
}
if (this.config.miscGroupKey && this.config.miscGroupKey === '') {
throw new Error('miscGroupKey cannot be empty');
}
const root = this.parseStrings(values, 0);
return { root };
}
parseStrings(values: string[], level: number): Node {
const node: Node = {
groups: new Map(),
values: [],
descendants: 0,
};
// go through each value and group it by its
// prefix, for the current level
for (const value of values) {
// skip empty values
if (value.trim() === '') {
continue;
}
const prefix = prefixDelimited(value, level);
// do we have this group?
let group = node.groups.get(prefix);
if (!group) {
// create a new group
group = { groups: new Map(), values: [], descendants: 0 };
node.groups.set(prefix, group);
}
// add the value to the group
group.values.push(value);
group.descendants++;
}
// check if we need to collapse or split any groups
const miscGroupValues: string[] = [];
for (let [key, group] of node.groups.entries()) {
if (group.values.length < this.config.minGroupSize) {
// this group is too small
if (this.config.miscGroupKey) {
// put the values into the miscGroupValues
miscGroupValues.push(...group.values);
} else {
// put the values into the node itself
node.values.push(...group.values);
}
// keep track of the descendants
node.descendants += group.values.length;
// remove the group
node.groups.delete(key);
} else if (group.values.length > this.config.idealMaxGroupSize && level < this.config.maxDepth - 1) {
// this group is too big - see if we can split it into
// subgroups
group = this.parseStrings(group.values, level + 1);
node.groups.set(key, group);
node.descendants += group.descendants;
} else {
node.descendants += group.descendants;
}
}
if (this.config.miscGroupKey && miscGroupValues.length > 0) {
// looks like we have some values for a misc group
const group: Node = {
groups: new Map(),
values: miscGroupValues,
descendants: miscGroupValues.length,
};
node.groups.set(this.config.miscGroupKey, group);
}
return node;
}
}
export interface Node {
groups: Map<string, Node>;
values: string[];
descendants: number;
}
// PrefixDelimited extracts the prefix of a string at the given level.
function prefixDelimited(s: string, level: number): string {
let delimiterCount = 0;
for (let i = 0; i < s.length; i++) {
const char = s.charCodeAt(i);
// Check if the character is not a letter or digit (non-alphanumeric)
if (
!(
(
(char >= 0x30 && char <= 0x39) || // 0-9
(char >= 0x41 && char <= 0x5a) || // A-Z
(char >= 0x61 && char <= 0x7a) || // a-z
(char >= 0xc0 && char <= 0xd6) || // Latin-1 Supplement and Extended-A
(char >= 0xd8 && char <= 0xf6) || // Latin-1 Supplement and Extended-A
(char >= 0xf8 && char <= 0xff) || // Latin-1 Supplement and Extended-A
(char >= 0x0100 && char <= 0x017f)
) // Latin Extended-A
)
) {
delimiterCount++;
if (delimiterCount > level) {
return s.slice(0, i);
}
}
}
return s; // Return the entire string if the level is higher than the number of delimiters.
}

View File

@@ -0,0 +1,263 @@
adhoc_channel_total
agent
agent_build_info
agent_config_hash
agent_config_last_load_success_timestamp_seconds
agent_config_last_load_successful
agent_config_load_failures
agent_config_load_failures_total
agent_inflight_requests
agent_metrics_active_configs
agent_metrics_active_instances
agent_metrics_cleaner_abandoned_storage
agent_metrics_cleaner_cleanup_seconds
agent_metrics_cleaner_errors
agent_metrics_cleaner_errors_total
agent_metrics_cleaner_managed_storage
agent_metrics_cleaner_success
agent_metrics_cleaner_success_total
agent_metrics_configs_changed
agent_metrics_configs_changed_total
agent_metrics_ha_configs_created
agent_metrics_ha_configs_created_total
agent_metrics_ha_configs_deleted
agent_metrics_ha_configs_deleted_total
agent_metrics_ha_configs_updated
agent_metrics_ha_configs_updated_total
agent_request_duration_seconds
agent_request_message_bytes
agent_response_message_bytes
agent_tcp_connections
agent_tcp_connections_limit
agent_wal_exemplars_appended
agent_wal_exemplars_appended_total
agent_wal_out_of_order_samples
agent_wal_samples_appended
agent_wal_samples_appended_total
agent_wal_storage_active_series
agent_wal_storage_created_series
agent_wal_storage_created_series_total
agent_wal_storage_deleted_series
agent_wal_storage_removed_series
agent_wal_storage_removed_series_total
aggregator_discovery_aggregation_count_total
aggregator_openapi_v2_regeneration_count
aggregator_openapi_v2_regeneration_duration
aggregator_unavailable_apiservice
aggregator_unavailable_apiservice_total
apiextensions_openapi_v2_regeneration_count
apiextensions_openapi_v3_regeneration_count
apiserver_admission_controller_admission_duration_seconds
apiserver_admission_step_admission_duration_seconds
apiserver_admission_step_admission_duration_seconds_summary
apiserver_admission_webhook_admission_duration_seconds
apiserver_admission_webhook_fail_open_count
apiserver_admission_webhook_rejection_count
apiserver_admission_webhook_request_total
apiserver_audit_event_total
apiserver_audit_requests_rejected_total
apiserver_cache_list_fetched_objects_total
apiserver_cache_list_returned_objects_total
apiserver_cache_list_total
apiserver_cel_compilation_duration_seconds
apiserver_cel_evaluation_duration_seconds
apiserver_client_certificate_expiration_seconds
apiserver_crd_webhook_conversion_duration_seconds
apiserver_current_inflight_requests
apiserver_current_inqueue_requests
apiserver_delegated_authn_request_duration_seconds
apiserver_delegated_authn_request_total
apiserver_delegated_authz_request_duration_seconds
apiserver_delegated_authz_request_total
apiserver_egress_dialer_dial_duration_seconds
apiserver_egress_dialer_dial_failure_count
apiserver_egress_dialer_dial_start_total
apiserver_envelope_encryption_dek_cache_fill_percent
apiserver_flowcontrol_current_executing_requests
apiserver_flowcontrol_current_inqueue_requests
apiserver_flowcontrol_current_limit_seats
apiserver_flowcontrol_current_r
apiserver_flowcontrol_demand_seats
apiserver_flowcontrol_demand_seats_average
apiserver_flowcontrol_demand_seats_high_watermark
apiserver_flowcontrol_demand_seats_smoothed
apiserver_flowcontrol_demand_seats_stdev
apiserver_flowcontrol_dispatch_r
apiserver_flowcontrol_dispatched_requests_total
apiserver_flowcontrol_latest_s
apiserver_flowcontrol_lower_limit_seats
apiserver_flowcontrol_next_discounted_s_bounds
apiserver_flowcontrol_next_s_bounds
apiserver_flowcontrol_nominal_limit_seats
apiserver_flowcontrol_priority_level_request_utilization
apiserver_flowcontrol_priority_level_seat_utilization
apiserver_flowcontrol_read_vs_write_current_requests
apiserver_flowcontrol_request_concurrency_in_use
apiserver_flowcontrol_request_concurrency_limit
apiserver_flowcontrol_request_execution_seconds
apiserver_flowcontrol_request_queue_length_after_enqueue
apiserver_flowcontrol_request_wait_duration_seconds
apiserver_flowcontrol_seat_fair_frac
apiserver_flowcontrol_target_seats
apiserver_flowcontrol_upper_limit_seats
apiserver_flowcontrol_watch_count_samples
apiserver_flowcontrol_work_estimated_seats
apiserver_init_events_total
apiserver_kube_aggregator_x509_insecure_sha1_total
apiserver_kube_aggregator_x509_missing_san_total
apiserver_longrunning_requests
apiserver_request_aborts_total
apiserver_request_duration_seconds
apiserver_request_filter_duration_seconds
apiserver_request_post_timeout_total
apiserver_request_sli_duration_seconds
apiserver_request_slo_duration_seconds
apiserver_request_terminations_total
apiserver_request_timestamp_comparison_time
apiserver_request_total
apiserver_requested_deprecated_apis
apiserver_response_sizes
apiserver_selfrequest_total
apiserver_storage_data_key_generation_duration_seconds
apiserver_storage_data_key_generation_failures_total
apiserver_storage_db_total_size_in_bytes
apiserver_storage_decode_errors_total
apiserver_storage_envelope_transformation_cache_misses_total
apiserver_storage_list_evaluated_objects_total
apiserver_storage_list_fetched_objects_total
apiserver_storage_list_returned_objects_total
apiserver_storage_list_total
apiserver_storage_objects
apiserver_terminated_watchers_total
apiserver_tls_handshake_errors_total
apiserver_watch_cache_events_dispatched_total
apiserver_watch_cache_events_received_total
apiserver_watch_cache_initializations_total
apiserver_watch_events_sizes
apiserver_watch_events_total
apiserver_webhooks_x509_insecure_sha1_total
apiserver_webhooks_x509_missing_san_total
attachdetach_controller_forced_detaches
authenticated_user_requests
authentication_attempts
authentication_duration_seconds
authentication_token_cache_active_fetch_count
authentication_token_cache_fetch_total
authentication_token_cache_request_duration_seconds
authentication_token_cache_request_total
blackbox_exporter_config_last_reload_success_timestamp_seconds
blackbox_exporter_config_last_reload_successful
blackbox_module_unknown_total
cadvisor_version_info
certwatcher_read_certificate_errors_total
certwatcher_read_certificate_total
config_reload_errors
config_reload_spurious
config_reload_total
conntrack_exporter_build_info
conntrack_pod_connections
conntrack_pod_ip_table_retrieve_duration_seconds
conntrack_table_read_duration_seconds
container_blkio_device_usage_total
container_cpu_cfs_periods_total
container_cpu_cfs_throttled_periods_total
container_cpu_cfs_throttled_seconds_total
container_cpu_load_average_10s
container_cpu_system_seconds_total
container_cpu_usage_seconds_total
container_cpu_user_seconds_total
container_file_descriptors
container_fs_inodes_free
container_fs_inodes_total
container_fs_io_current
container_fs_io_time_seconds_total
container_fs_io_time_weighted_seconds_total
container_fs_limit_bytes
container_fs_read_seconds_total
container_fs_reads_bytes_total
container_fs_reads_merged_total
container_fs_reads_total
container_fs_sector_reads_total
container_fs_sector_writes_total
container_fs_usage_bytes
container_fs_write_seconds_total
container_fs_writes_bytes_total
container_fs_writes_merged_total
container_fs_writes_total
container_last_seen
container_memory_cache
container_memory_failcnt
container_memory_failures_total
container_memory_mapped_file
container_memory_max_usage_bytes
container_memory_rss
container_memory_swap
container_memory_usage_bytes
container_memory_working_set_bytes
container_network_receive_bytes_total
container_network_receive_errors_total
container_network_receive_packets_dropped_total
container_network_receive_packets_total
container_network_transmit_bytes_total
container_network_transmit_errors_total
container_network_transmit_packets_dropped_total
container_network_transmit_packets_total
container_oom_events_total
container_processes
container_scrape_error
container_sockets
container_spec_cpu_period
container_spec_cpu_quota
container_spec_cpu_shares
container_spec_memory_limit_bytes
container_spec_memory_reservation_limit_bytes
container_spec_memory_swap_limit_bytes
container_start_time_seconds
container_tasks_state
container_threads
container_threads_max
container_ulimits_soft
controller_runtime_active_workers
controller_runtime_max_concurrent_reconciles
controller_runtime_reconcile_errors_total
controller_runtime_reconcile_time_seconds
controller_runtime_reconcile_total
controller_runtime_webhook_latency_seconds
controller_runtime_webhook_requests_in_flight
controller_runtime_webhook_requests_total
coredns_build_info
coredns_cache_entries
coredns_cache_hits_total
coredns_cache_misses_total
coredns_cache_requests_total
coredns_dns_request_duration_seconds
coredns_dns_request_size_bytes
coredns_dns_requests_total
coredns_dns_response_size_bytes
coredns_dns_responses_total
coredns_forward_conn_cache_hits_total
coredns_forward_conn_cache_misses_total
coredns_forward_healthcheck_broken_total
coredns_forward_healthcheck_failures_total
coredns_forward_max_concurrent_rejects_total
coredns_forward_request_duration_seconds
coredns_forward_requests_total
coredns_forward_responses_total
coredns_health_request_duration_seconds
coredns_health_request_failures_total
coredns_hosts_entries
coredns_hosts_reload_timestamp_seconds
coredns_kubernetes_dns_programming_duration_seconds
coredns_local_localhost_requests_total
coredns_panics_total
coredns_plugin_enabled
coredns_reload_failed_total
cortex_experimental_features_in_use
cortex_experimental_features_in_use_total
cronjob_controller_job_creation_skew_duration_seconds
csi_operations_seconds
deprecated_flags_inuse
deprecated_flags_inuse_total
disabled_metric_total
ecs_cpu_seconds_total
ecs_cpu_utilized

View File

@@ -95,6 +95,21 @@ type Interactions = {
// The number of search terms activated when the selection was made
searchTermCount: number | null;
};
// User opens/closes the prefix filter dropdown
prefix_filter_clicked: {
from: (
// By clicking "Select" on a metric panel when on the no-metric-selected metrics list view
| 'metric_list'
// By clicking "Select" on a metric panel when on the related metrics tab
| 'related_metrics'
)
action: (
// Opens the dropdown
| 'open'
// Closes the dropdown
| 'close'
)
};
};
const PREFIX = 'grafana_explore_metrics_';

View File

@@ -792,6 +792,9 @@
"split-widen": "Widen pane"
}
},
"explore-metrics": {
"viewBy": "View by"
},
"export": {
"json": {
"cancel-button": "Cancel",

View File

@@ -792,6 +792,9 @@
"split-widen": "Ŵįđęʼn päʼnę"
}
},
"explore-metrics": {
"viewBy": "Vįęŵ þy"
},
"export": {
"json": {
"cancel-button": "Cäʼnčęľ",