123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658 |
- import { css, cx } from '@emotion/css';
- import React, { ChangeEvent } from 'react';
- import { FixedSizeList } from 'react-window';
- import { GrafanaTheme } from '@grafana/data';
- import {
- Button,
- HorizontalGroup,
- Input,
- Label,
- LoadingPlaceholder,
- stylesFactory,
- withTheme,
- BrowserLabel as PromLabel,
- } from '@grafana/ui';
- import PromQlLanguageProvider from '../language_provider';
- import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils';
- // Hard limit on labels to render
- const EMPTY_SELECTOR = '{}';
- const METRIC_LABEL = '__name__';
- const LIST_ITEM_SIZE = 25;
- export interface BrowserProps {
- languageProvider: PromQlLanguageProvider;
- onChange: (selector: string) => void;
- theme: GrafanaTheme;
- autoSelect?: number;
- hide?: () => void;
- lastUsedLabels: string[];
- storeLastUsedLabels: (labels: string[]) => void;
- deleteLastUsedLabels: () => void;
- }
- interface BrowserState {
- labels: SelectableLabel[];
- labelSearchTerm: string;
- metricSearchTerm: string;
- status: string;
- error: string;
- validationStatus: string;
- valueSearchTerm: string;
- }
- interface FacettableValue {
- name: string;
- selected?: boolean;
- details?: string;
- }
- export interface SelectableLabel {
- name: string;
- selected?: boolean;
- loading?: boolean;
- values?: FacettableValue[];
- hidden?: boolean;
- facets?: number;
- }
- export function buildSelector(labels: SelectableLabel[]): string {
- let singleMetric = '';
- const selectedLabels = [];
- for (const label of labels) {
- if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
- const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
- if (selectedValues.length > 1) {
- selectedLabels.push(`${label.name}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`);
- } else if (selectedValues.length === 1) {
- if (label.name === METRIC_LABEL) {
- singleMetric = selectedValues[0];
- } else {
- selectedLabels.push(`${label.name}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
- }
- }
- }
- }
- return [singleMetric, '{', selectedLabels.join(','), '}'].join('');
- }
- export function facetLabels(
- labels: SelectableLabel[],
- possibleLabels: Record<string, string[]>,
- lastFacetted?: string
- ): SelectableLabel[] {
- return labels.map((label) => {
- const possibleValues = possibleLabels[label.name];
- if (possibleValues) {
- let existingValues: FacettableValue[];
- if (label.name === lastFacetted && label.values) {
- // Facetting this label, show all values
- existingValues = label.values;
- } else {
- // Keep selection in other facets
- const selectedValues: Set<string> = new Set(
- label.values?.filter((value) => value.selected).map((value) => value.name) || []
- );
- // Values for this label have not been requested yet, let's use the facetted ones as the initial values
- existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
- }
- return {
- ...label,
- loading: false,
- values: existingValues,
- hidden: !possibleValues,
- facets: existingValues.length,
- };
- }
- // Label is facetted out, hide all values
- return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
- });
- }
- const getStyles = stylesFactory((theme: GrafanaTheme) => ({
- wrapper: css`
- background-color: ${theme.colors.bg2};
- padding: ${theme.spacing.sm};
- width: 100%;
- `,
- list: css`
- margin-top: ${theme.spacing.sm};
- display: flex;
- flex-wrap: wrap;
- max-height: 200px;
- overflow: auto;
- align-content: flex-start;
- `,
- section: css`
- & + & {
- margin: ${theme.spacing.md} 0;
- }
- position: relative;
- `,
- selector: css`
- font-family: ${theme.typography.fontFamily.monospace};
- margin-bottom: ${theme.spacing.sm};
- `,
- status: css`
- padding: ${theme.spacing.xs};
- color: ${theme.colors.textSemiWeak};
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- /* using absolute positioning because flex interferes with ellipsis */
- position: absolute;
- width: 50%;
- right: 0;
- text-align: right;
- transition: opacity 100ms linear;
- opacity: 0;
- `,
- statusShowing: css`
- opacity: 1;
- `,
- error: css`
- color: ${theme.palette.brandDanger};
- `,
- valueList: css`
- margin-right: ${theme.spacing.sm};
- `,
- valueListWrapper: css`
- border-left: 1px solid ${theme.colors.border2};
- margin: ${theme.spacing.sm} 0;
- padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm};
- `,
- valueListArea: css`
- display: flex;
- flex-wrap: wrap;
- margin-top: ${theme.spacing.sm};
- `,
- valueTitle: css`
- margin-left: -${theme.spacing.xs};
- margin-bottom: ${theme.spacing.sm};
- `,
- validationStatus: css`
- padding: ${theme.spacing.xs};
- margin-bottom: ${theme.spacing.sm};
- color: ${theme.colors.textStrong};
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- `,
- }));
- /**
- * TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible
- * to create a single, generic component.
- */
- export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> {
- valueListsRef = React.createRef<HTMLDivElement>();
- state: BrowserState = {
- labels: [] as SelectableLabel[],
- labelSearchTerm: '',
- metricSearchTerm: '',
- status: 'Ready',
- error: '',
- validationStatus: '',
- valueSearchTerm: '',
- };
- onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
- this.setState({ labelSearchTerm: event.target.value });
- };
- onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
- this.setState({ metricSearchTerm: event.target.value });
- };
- onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
- this.setState({ valueSearchTerm: event.target.value });
- };
- onClickRunQuery = () => {
- const selector = buildSelector(this.state.labels);
- this.props.onChange(selector);
- };
- onClickRunRateQuery = () => {
- const selector = buildSelector(this.state.labels);
- const query = `rate(${selector}[$__interval])`;
- this.props.onChange(query);
- };
- onClickClear = () => {
- this.setState((state) => {
- const labels: SelectableLabel[] = state.labels.map((label) => ({
- ...label,
- values: undefined,
- selected: false,
- loading: false,
- hidden: false,
- facets: undefined,
- }));
- return {
- labels,
- labelSearchTerm: '',
- metricSearchTerm: '',
- status: '',
- error: '',
- validationStatus: '',
- valueSearchTerm: '',
- };
- });
- this.props.deleteLastUsedLabels();
- // Get metrics
- this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
- };
- onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
- const label = this.state.labels.find((l) => l.name === name);
- if (!label) {
- return;
- }
- // Toggle selected state
- const selected = !label.selected;
- let nextValue: Partial<SelectableLabel> = { selected };
- if (label.values && !selected) {
- // Deselect all values if label was deselected
- const values = label.values.map((value) => ({ ...value, selected: false }));
- nextValue = { ...nextValue, facets: 0, values };
- }
- // Resetting search to prevent empty results
- this.setState({ labelSearchTerm: '' });
- this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
- };
- onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
- const label = this.state.labels.find((l) => l.name === name);
- if (!label || !label.values) {
- return;
- }
- // Resetting search to prevent empty results
- this.setState({ labelSearchTerm: '' });
- // Toggling value for selected label, leaving other values intact
- const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
- this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
- };
- onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
- // Finding special metric label
- const label = this.state.labels.find((l) => l.name === name);
- if (!label || !label.values) {
- return;
- }
- // Resetting search to prevent empty results
- this.setState({ metricSearchTerm: '' });
- // Toggling value for selected label, leaving other values intact
- const values = label.values.map((v) => ({
- ...v,
- selected: v.name === value || v.selected ? !v.selected : v.selected,
- }));
- // Toggle selected state of special metrics label
- const selected = values.some((v) => v.selected);
- this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
- };
- onClickValidate = () => {
- const selector = buildSelector(this.state.labels);
- this.validateSelector(selector);
- };
- updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
- this.setState((state) => {
- const labels: SelectableLabel[] = state.labels.map((label) => {
- if (label.name === name) {
- return { ...label, ...updatedFields };
- }
- return label;
- });
- // New status overrides errors
- const error = status ? '' : state.error;
- return { labels, status, error, validationStatus: '' };
- }, cb);
- }
- componentDidMount() {
- const { languageProvider, lastUsedLabels } = this.props;
- if (languageProvider) {
- const selectedLabels: string[] = lastUsedLabels;
- languageProvider.start().then(() => {
- let rawLabels: string[] = languageProvider.getLabelKeys();
- // Get metrics
- this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
- // Auto-select previously selected labels
- const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
- name: label,
- selected: selectedLabels.includes(label),
- loading: false,
- }));
- // Pre-fetch values for selected labels
- this.setState({ labels }, () => {
- this.state.labels.forEach((label) => {
- if (label.selected) {
- this.fetchValues(label.name, EMPTY_SELECTOR);
- }
- });
- });
- });
- }
- }
- doFacettingForLabel(name: string) {
- const label = this.state.labels.find((l) => l.name === name);
- if (!label) {
- return;
- }
- const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
- this.props.storeLastUsedLabels(selectedLabels);
- if (label.selected) {
- // Refetch values for newly selected label...
- if (!label.values) {
- this.fetchValues(name, buildSelector(this.state.labels));
- }
- } else {
- // Only need to facet when deselecting labels
- this.doFacetting();
- }
- }
- doFacetting = (lastFacetted?: string) => {
- const selector = buildSelector(this.state.labels);
- if (selector === EMPTY_SELECTOR) {
- // Clear up facetting
- const labels: SelectableLabel[] = this.state.labels.map((label) => {
- return { ...label, facets: 0, values: undefined, hidden: false };
- });
- this.setState({ labels }, () => {
- // Get fresh set of values
- this.state.labels.forEach(
- (label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector)
- );
- });
- } else {
- // Do facetting
- this.fetchSeries(selector, lastFacetted);
- }
- };
- async fetchValues(name: string, selector: string) {
- const { languageProvider } = this.props;
- this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
- try {
- let rawValues = await languageProvider.getLabelValues(name);
- // If selector changed, clear loading state and discard result by returning early
- if (selector !== buildSelector(this.state.labels)) {
- this.updateLabelState(name, { loading: false });
- return;
- }
- const values: FacettableValue[] = [];
- const { metricsMetadata } = languageProvider;
- for (const labelValue of rawValues) {
- const value: FacettableValue = { name: labelValue };
- // Adding type/help text to metrics
- if (name === METRIC_LABEL && metricsMetadata) {
- const meta = metricsMetadata[labelValue];
- if (meta) {
- value.details = `(${meta.type}) ${meta.help}`;
- }
- }
- values.push(value);
- }
- this.updateLabelState(name, { values, loading: false });
- } catch (error) {
- console.error(error);
- }
- }
- async fetchSeries(selector: string, lastFacetted?: string) {
- const { languageProvider } = this.props;
- if (lastFacetted) {
- this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
- }
- try {
- const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
- // If selector changed, clear loading state and discard result by returning early
- if (selector !== buildSelector(this.state.labels)) {
- if (lastFacetted) {
- this.updateLabelState(lastFacetted, { loading: false });
- }
- return;
- }
- if (Object.keys(possibleLabels).length === 0) {
- this.setState({ error: `Empty results, no matching label for ${selector}` });
- return;
- }
- const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
- this.setState({ labels, error: '' });
- if (lastFacetted) {
- this.updateLabelState(lastFacetted, { loading: false });
- }
- } catch (error) {
- console.error(error);
- }
- }
- async validateSelector(selector: string) {
- const { languageProvider } = this.props;
- this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
- const streams = await languageProvider.fetchSeries(selector);
- this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` });
- }
- render() {
- const { theme } = this.props;
- const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
- const styles = getStyles(theme);
- if (labels.length === 0) {
- return (
- <div className={styles.wrapper}>
- <LoadingPlaceholder text="Loading labels..." />
- </div>
- );
- }
- // Filter metrics
- let metrics = labels.find((label) => label.name === METRIC_LABEL);
- if (metrics && metricSearchTerm) {
- metrics = {
- ...metrics,
- values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
- };
- }
- // Filter labels
- let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
- if (labelSearchTerm) {
- nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
- }
- // Filter non-metric label values
- let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
- if (valueSearchTerm) {
- selectedLabels = selectedLabels.map((label) => ({
- ...label,
- values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
- }));
- }
- const selector = buildSelector(this.state.labels);
- const empty = selector === EMPTY_SELECTOR;
- const metricCount = metrics?.values?.length || 0;
- return (
- <div className={styles.wrapper}>
- <HorizontalGroup align="flex-start" spacing="lg">
- <div>
- <div className={styles.section}>
- <Label description="Once a metric is selected only possible labels are shown.">1. Select a metric</Label>
- <div>
- <Input
- onChange={this.onChangeMetricSearch}
- aria-label="Filter expression for metric"
- value={metricSearchTerm}
- />
- </div>
- <div role="list" className={styles.valueListWrapper}>
- <FixedSizeList
- height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
- itemCount={metricCount}
- itemSize={LIST_ITEM_SIZE}
- itemKey={(i) => (metrics!.values as FacettableValue[])[i].name}
- width={300}
- className={styles.valueList}
- >
- {({ index, style }) => {
- const value = metrics?.values?.[index];
- if (!value) {
- return null;
- }
- return (
- <div style={style}>
- <PromLabel
- name={metrics!.name}
- value={value?.name}
- title={value.details}
- active={value?.selected}
- onClick={this.onClickMetric}
- searchTerm={metricSearchTerm}
- />
- </div>
- );
- }}
- </FixedSizeList>
- </div>
- </div>
- </div>
- <div>
- <div className={styles.section}>
- <Label description="Once label values are selected, only possible label combinations are shown.">
- 2. Select labels to search in
- </Label>
- <div>
- <Input
- onChange={this.onChangeLabelSearch}
- aria-label="Filter expression for label"
- value={labelSearchTerm}
- />
- </div>
- {/* Using fixed height here to prevent jumpy layout */}
- <div className={styles.list} style={{ height: 120 }}>
- {nonMetricLabels.map((label) => (
- <PromLabel
- key={label.name}
- name={label.name}
- loading={label.loading}
- active={label.selected}
- hidden={label.hidden}
- facets={label.facets}
- onClick={this.onClickLabel}
- searchTerm={labelSearchTerm}
- />
- ))}
- </div>
- </div>
- <div className={styles.section}>
- <Label description="Use the search field to find values across selected labels.">
- 3. Select (multiple) values for your labels
- </Label>
- <div>
- <Input
- onChange={this.onChangeValueSearch}
- aria-label="Filter expression for label values"
- value={valueSearchTerm}
- />
- </div>
- <div className={styles.valueListArea} ref={this.valueListsRef}>
- {selectedLabels.map((label) => (
- <div
- role="list"
- key={label.name}
- aria-label={`Values for ${label.name}`}
- className={styles.valueListWrapper}
- >
- <div className={styles.valueTitle}>
- <PromLabel
- name={label.name}
- loading={label.loading}
- active={label.selected}
- hidden={label.hidden}
- //If no facets, we want to show number of all label values
- facets={label.facets || label.values?.length}
- onClick={this.onClickLabel}
- />
- </div>
- <FixedSizeList
- height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
- itemCount={label.values?.length || 0}
- itemSize={28}
- itemKey={(i) => (label.values as FacettableValue[])[i].name}
- width={200}
- className={styles.valueList}
- >
- {({ index, style }) => {
- const value = label.values?.[index];
- if (!value) {
- return null;
- }
- return (
- <div style={style}>
- <PromLabel
- name={label.name}
- value={value?.name}
- active={value?.selected}
- onClick={this.onClickValue}
- searchTerm={valueSearchTerm}
- />
- </div>
- );
- }}
- </FixedSizeList>
- </div>
- ))}
- </div>
- </div>
- </div>
- </HorizontalGroup>
- <div className={styles.section}>
- <Label>4. Resulting selector</Label>
- <div aria-label="selector" className={styles.selector}>
- {selector}
- </div>
- {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
- <HorizontalGroup>
- <Button aria-label="Use selector for query button" disabled={empty} onClick={this.onClickRunQuery}>
- Use query
- </Button>
- <Button
- aria-label="Use selector as metrics button"
- variant="secondary"
- disabled={empty}
- onClick={this.onClickRunRateQuery}
- >
- Use as rate query
- </Button>
- <Button
- aria-label="Validate submit button"
- variant="secondary"
- disabled={empty}
- onClick={this.onClickValidate}
- >
- Validate selector
- </Button>
- <Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}>
- Clear
- </Button>
- <div className={cx(styles.status, (status || error) && styles.statusShowing)}>
- <span className={error ? styles.error : ''}>{error || status}</span>
- </div>
- </HorizontalGroup>
- </div>
- </div>
- );
- }
- }
- export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser);
|