Logs Panel: Table UI - Misc UI tweaks (#78150)

* Miscellaneous UI tweaks for logs table UI in explore
This commit is contained in:
Galen Kistler 2023-11-29 10:57:04 -06:00 committed by GitHub
parent 0b65f900aa
commit fd863cfc93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 71 additions and 92 deletions

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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 (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 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>

View File

@ -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