mirror of https://github.com/grafana/grafana.git
				
				
				
			Logs Panel: Table UI - Misc UI tweaks (#78150)
* Miscellaneous UI tweaks for logs table UI in explore
This commit is contained in:
		
							parent
							
								
									0b65f900aa
								
							
						
					
					
						commit
						fd863cfc93
					
				| 
						 | 
				
			
			@ -613,13 +613,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
 | 
			
		|||
              )
 | 
			
		||||
            ) : null,
 | 
			
		||||
          ]}
 | 
			
		||||
          title={
 | 
			
		||||
            config.featureToggles.logsExploreTableVisualisation
 | 
			
		||||
              ? this.state.visualisationType === 'logs'
 | 
			
		||||
                ? 'Logs'
 | 
			
		||||
                : 'Table'
 | 
			
		||||
              : 'Logs'
 | 
			
		||||
          }
 | 
			
		||||
          title={'Logs'}
 | 
			
		||||
          actions={
 | 
			
		||||
            <>
 | 
			
		||||
              {config.featureToggles.logsExploreTableVisualisation && (
 | 
			
		||||
| 
						 | 
				
			
			@ -627,16 +621,16 @@ class UnthemedLogs extends PureComponent<Props, State> {
 | 
			
		|||
                  <RadioButtonGroup
 | 
			
		||||
                    className={styles.visualisationTypeRadio}
 | 
			
		||||
                    options={[
 | 
			
		||||
                      {
 | 
			
		||||
                        label: 'Table',
 | 
			
		||||
                        value: 'table',
 | 
			
		||||
                        description: 'Show results in table visualisation',
 | 
			
		||||
                      },
 | 
			
		||||
                      {
 | 
			
		||||
                        label: 'Logs',
 | 
			
		||||
                        value: 'logs',
 | 
			
		||||
                        description: 'Show results in logs visualisation',
 | 
			
		||||
                      },
 | 
			
		||||
                      {
 | 
			
		||||
                        label: 'Table',
 | 
			
		||||
                        value: 'table',
 | 
			
		||||
                        description: 'Show results in table visualisation',
 | 
			
		||||
                      },
 | 
			
		||||
                    ]}
 | 
			
		||||
                    size="sm"
 | 
			
		||||
                    value={this.state.visualisationType}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,12 +12,12 @@ function getStyles(theme: GrafanaTheme2) {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LogsColumnSearch(props: { onChange: (e: React.FormEvent<HTMLInputElement>) => void }) {
 | 
			
		||||
export function LogsColumnSearch(props: { onChange: (e: React.FormEvent<HTMLInputElement>) => void; value: string }) {
 | 
			
		||||
  const theme = useTheme2();
 | 
			
		||||
  const styles = getStyles(theme);
 | 
			
		||||
  return (
 | 
			
		||||
    <Field className={styles.searchWrap}>
 | 
			
		||||
      <Input type={'text'} placeholder={'Search fields by name'} onChange={props.onChange} />
 | 
			
		||||
      <Input value={props.value} type={'text'} placeholder={'Search fields by name'} onChange={props.onChange} />
 | 
			
		||||
    </Field>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,7 +13,15 @@ function getStyles(theme: GrafanaTheme2) {
 | 
			
		|||
      overflowY: 'scroll',
 | 
			
		||||
      height: 'calc(100% - 50px)',
 | 
			
		||||
    }),
 | 
			
		||||
    columnHeaderButton: css({
 | 
			
		||||
      appearance: 'none',
 | 
			
		||||
      background: 'none',
 | 
			
		||||
      border: 'none',
 | 
			
		||||
      fontSize: theme.typography.pxToRem(11),
 | 
			
		||||
    }),
 | 
			
		||||
    columnHeader: css({
 | 
			
		||||
      display: 'flex',
 | 
			
		||||
      justifyContent: 'space-between',
 | 
			
		||||
      fontSize: theme.typography.h6.fontSize,
 | 
			
		||||
      background: theme.colors.background.secondary,
 | 
			
		||||
      position: 'sticky',
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +41,7 @@ export const LogsTableMultiSelect = (props: {
 | 
			
		|||
  toggleColumn: (columnName: string) => void;
 | 
			
		||||
  filteredColumnsWithMeta: Record<string, fieldNameMeta> | undefined;
 | 
			
		||||
  columnsWithMeta: Record<string, fieldNameMeta>;
 | 
			
		||||
  clear: () => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const theme = useTheme2();
 | 
			
		||||
  const styles = getStyles(theme);
 | 
			
		||||
| 
						 | 
				
			
			@ -41,11 +50,23 @@ export const LogsTableMultiSelect = (props: {
 | 
			
		|||
    <div className={styles.sidebarWrap}>
 | 
			
		||||
      {/* Sidebar columns */}
 | 
			
		||||
      <>
 | 
			
		||||
        <div className={styles.columnHeader}>
 | 
			
		||||
          Selected fields
 | 
			
		||||
          <button onClick={props.clear} className={styles.columnHeaderButton}>
 | 
			
		||||
            Reset
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <LogsTableNavColumn
 | 
			
		||||
          toggleColumn={props.toggleColumn}
 | 
			
		||||
          labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
 | 
			
		||||
          valueFilter={(value) => props.columnsWithMeta[value]?.active ?? false}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div className={styles.columnHeader}>Fields</div>
 | 
			
		||||
        <LogsTableNavColumn
 | 
			
		||||
          toggleColumn={props.toggleColumn}
 | 
			
		||||
          labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
 | 
			
		||||
          valueFilter={(value) => !!value}
 | 
			
		||||
          valueFilter={(value) => !props.columnsWithMeta[value]?.active}
 | 
			
		||||
        />
 | 
			
		||||
      </>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,10 @@ function getStyles(theme: GrafanaTheme2) {
 | 
			
		|||
    labelCount: css({
 | 
			
		||||
      marginLeft: theme.spacing(0.5),
 | 
			
		||||
      marginRight: theme.spacing(0.5),
 | 
			
		||||
      appearance: 'none',
 | 
			
		||||
      background: 'none',
 | 
			
		||||
      border: 'none',
 | 
			
		||||
      fontSize: theme.typography.pxToRem(11),
 | 
			
		||||
    }),
 | 
			
		||||
    wrap: css({
 | 
			
		||||
      display: 'flex',
 | 
			
		||||
| 
						 | 
				
			
			@ -29,13 +33,11 @@ function getStyles(theme: GrafanaTheme2) {
 | 
			
		|||
        top: 0,
 | 
			
		||||
      },
 | 
			
		||||
      '> span': {
 | 
			
		||||
        overflow: 'scroll',
 | 
			
		||||
        '&::-webkit-scrollbar': {
 | 
			
		||||
          display: 'none',
 | 
			
		||||
        },
 | 
			
		||||
        '&::-moz-scrollbar': {
 | 
			
		||||
          display: 'none',
 | 
			
		||||
        },
 | 
			
		||||
        overflow: 'hidden',
 | 
			
		||||
        textOverflow: 'ellipsis',
 | 
			
		||||
        whiteSpace: 'nowrap',
 | 
			
		||||
        display: 'block',
 | 
			
		||||
        maxWidth: '100%',
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    columnWrapper: css({
 | 
			
		||||
| 
						 | 
				
			
			@ -51,70 +53,19 @@ function getStyles(theme: GrafanaTheme2) {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
 | 
			
		||||
function sortLabels(labels: Record<string, fieldNameMeta>) {
 | 
			
		||||
  return (a: string, b: string) => {
 | 
			
		||||
    // First sort by active
 | 
			
		||||
    if (labels[a].active && labels[b].active) {
 | 
			
		||||
      // If both fields are active, sort time first
 | 
			
		||||
      if (labels[a]?.type === 'TIME_FIELD') {
 | 
			
		||||
        return -1;
 | 
			
		||||
      }
 | 
			
		||||
      if (labels[b]?.type === 'TIME_FIELD') {
 | 
			
		||||
        return 1;
 | 
			
		||||
      }
 | 
			
		||||
      // And then line second
 | 
			
		||||
      if (labels[a]?.type === 'BODY_FIELD') {
 | 
			
		||||
        return -1;
 | 
			
		||||
      }
 | 
			
		||||
      // special fields are next
 | 
			
		||||
      if (labels[b]?.type === 'BODY_FIELD') {
 | 
			
		||||
        return 1;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const la = labels[a];
 | 
			
		||||
    const lb = labels[b];
 | 
			
		||||
 | 
			
		||||
    if (labels[b].active && labels[a].active) {
 | 
			
		||||
      // Sort alphabetically
 | 
			
		||||
      if (a < b) {
 | 
			
		||||
        return -1;
 | 
			
		||||
    if (la != null && lb != null) {
 | 
			
		||||
      return (
 | 
			
		||||
        Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') ||
 | 
			
		||||
        Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') ||
 | 
			
		||||
        collator.compare(a, b)
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
      if (a > b) {
 | 
			
		||||
        return 1;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If just one label is active, sort it first
 | 
			
		||||
    if (labels[b].active) {
 | 
			
		||||
      return 1;
 | 
			
		||||
    }
 | 
			
		||||
    if (labels[a].active) {
 | 
			
		||||
      return -1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If both fields are special, and not selected, sort time first
 | 
			
		||||
    if (labels[a]?.type && labels[b]?.type) {
 | 
			
		||||
      if (labels[a]?.type === 'TIME_FIELD') {
 | 
			
		||||
        return -1;
 | 
			
		||||
      }
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If only one special field, stick to the top of inactive fields
 | 
			
		||||
    if (labels[a]?.type && !labels[b]?.type) {
 | 
			
		||||
      return -1;
 | 
			
		||||
    }
 | 
			
		||||
    // if the b field is special, sort it first
 | 
			
		||||
    if (!labels[a]?.type && labels[b]?.type) {
 | 
			
		||||
      return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Finally sort by name
 | 
			
		||||
    if (a < b) {
 | 
			
		||||
      return -1;
 | 
			
		||||
    }
 | 
			
		||||
    if (a > b) {
 | 
			
		||||
      return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // otherwise do not sort
 | 
			
		||||
    return 0;
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			@ -122,25 +73,31 @@ function sortLabels(labels: Record<string, fieldNameMeta>) {
 | 
			
		|||
 | 
			
		||||
export const LogsTableNavColumn = (props: {
 | 
			
		||||
  labels: Record<string, fieldNameMeta>;
 | 
			
		||||
  valueFilter: (value: number) => boolean;
 | 
			
		||||
  valueFilter: (value: string) => boolean;
 | 
			
		||||
  toggleColumn: (columnName: string) => void;
 | 
			
		||||
}): JSX.Element => {
 | 
			
		||||
  const { labels, valueFilter, toggleColumn } = props;
 | 
			
		||||
  const theme = useTheme2();
 | 
			
		||||
  const styles = getStyles(theme);
 | 
			
		||||
  const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labels[labelName].percentOfLinesWithLabel));
 | 
			
		||||
  const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName));
 | 
			
		||||
  if (labelKeys.length) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className={styles.columnWrapper}>
 | 
			
		||||
        {labelKeys.sort(sortLabels(labels)).map((labelName) => (
 | 
			
		||||
          <div className={styles.wrap} key={labelName}>
 | 
			
		||||
          <div
 | 
			
		||||
            title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`}
 | 
			
		||||
            className={styles.wrap}
 | 
			
		||||
            key={labelName}
 | 
			
		||||
          >
 | 
			
		||||
            <Checkbox
 | 
			
		||||
              className={styles.checkboxLabel}
 | 
			
		||||
              label={labelName}
 | 
			
		||||
              onChange={() => toggleColumn(labelName)}
 | 
			
		||||
              checked={labels[labelName]?.active ?? false}
 | 
			
		||||
            />
 | 
			
		||||
            <span className={styles.labelCount}>({labels[labelName]?.percentOfLinesWithLabel}%)</span>
 | 
			
		||||
            <button className={styles.labelCount} onClick={() => toggleColumn(labelName)}>
 | 
			
		||||
              {labels[labelName]?.percentOfLinesWithLabel}%
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,4 @@
 | 
			
		|||
import { css } from '@emotion/css';
 | 
			
		||||
import { debounce } from 'lodash';
 | 
			
		||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -51,6 +50,7 @@ export function LogsTableWrap(props: Props) {
 | 
			
		|||
 | 
			
		||||
  // Filtered copy of columnsWithMeta that only includes matching results
 | 
			
		||||
  const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined);
 | 
			
		||||
  const [searchValue, setSearchValue] = useState<string>('');
 | 
			
		||||
 | 
			
		||||
  const height = getLogsTableHeight();
 | 
			
		||||
  const panelStateRefId = props?.panelState?.refId;
 | 
			
		||||
| 
						 | 
				
			
			@ -251,6 +251,14 @@ export function LogsTableWrap(props: Props) {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const clearSelection = () => {
 | 
			
		||||
    const pendingLabelState = { ...columnsWithMeta };
 | 
			
		||||
    Object.keys(pendingLabelState).forEach((key) => {
 | 
			
		||||
      pendingLabelState[key].active = !!pendingLabelState[key].type;
 | 
			
		||||
    });
 | 
			
		||||
    setColumnsWithMeta(pendingLabelState);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Toggle a column on or off when the user interacts with an element in the multi-select sidebar
 | 
			
		||||
  const toggleColumn = (columnName: fieldName) => {
 | 
			
		||||
    if (!columnsWithMeta || !(columnName in columnsWithMeta)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -320,14 +328,12 @@ export function LogsTableWrap(props: Props) {
 | 
			
		|||
    fuzzySearch(Object.keys(columnsWithMeta), needle, dispatcher);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Debounce fuzzy search
 | 
			
		||||
  const debouncedSearch = debounce(search, 500);
 | 
			
		||||
 | 
			
		||||
  // onChange handler for search input
 | 
			
		||||
  const onSearchInputChange = (e: React.FormEvent<HTMLInputElement>) => {
 | 
			
		||||
    const value = e.currentTarget?.value;
 | 
			
		||||
    setSearchValue(value);
 | 
			
		||||
    if (value) {
 | 
			
		||||
      debouncedSearch(value);
 | 
			
		||||
      search(value);
 | 
			
		||||
    } else {
 | 
			
		||||
      // If the search input is empty, reset the local search state.
 | 
			
		||||
      setFilteredColumnsWithMeta(undefined);
 | 
			
		||||
| 
						 | 
				
			
			@ -376,11 +382,12 @@ export function LogsTableWrap(props: Props) {
 | 
			
		|||
      </div>
 | 
			
		||||
      <div className={styles.wrapper}>
 | 
			
		||||
        <section className={styles.sidebar}>
 | 
			
		||||
          <LogsColumnSearch onChange={onSearchInputChange} />
 | 
			
		||||
          <LogsColumnSearch value={searchValue} onChange={onSearchInputChange} />
 | 
			
		||||
          <LogsTableMultiSelect
 | 
			
		||||
            toggleColumn={toggleColumn}
 | 
			
		||||
            filteredColumnsWithMeta={filteredColumnsWithMeta}
 | 
			
		||||
            columnsWithMeta={columnsWithMeta}
 | 
			
		||||
            clear={clearSelection}
 | 
			
		||||
          />
 | 
			
		||||
        </section>
 | 
			
		||||
        <LogsTable
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue