Improve Log Messages UX (#4363)

* Refactor ExpandableMessage to use React Portals

* Display "Clear Filters" button when more than 3 filters are present

* Move logs truncation toggle from options overlay to filter bar

* Simplify styles and markup

Ensure that the ... truncation happens
Showing a "zoom in" cursor when log messages can be clicked

* Update changelog

* Remove rogue file

* Type props in constructor and remove unecessary condition

* Remove spaces
pull/4374/head
Alex Paxton 2018-09-06 11:07:40 -07:00 committed by GitHub
parent 7201521b35
commit 5f6cb054ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 206 additions and 124 deletions

View File

@ -19,6 +19,7 @@
1. [#4227](https://github.com/influxdata/chronograf/pull/4227): Redesign Cell Editor Overlay for reuse in other parts of application
1. [#4268](https://github.com/influxdata/chronograf/pull/4268): Clear logs after searching
1. [#4253](https://github.com/influxdata/chronograf/pull/4253): Add search expression highlighting to log lines
1. [#4363](https://github.com/influxdata/chronograf/pull/4363): Move log message truncation controls into logs filter bar
### UI Improvements
@ -28,6 +29,8 @@
### Bug Fixes
1. [#4272](https://github.com/influxdata/chronograf/pull/4272): Fix logs loading description not displaying
1. [#4363](https://github.com/influxdata/chronograf/pull/4363): Position expanded log messages above logs table
## v1.6.2 [unreleased]

View File

@ -69,6 +69,7 @@ export enum ActionTypes {
AddFilter = 'LOGS_ADD_FILTER',
RemoveFilter = 'LOGS_REMOVE_FILTER',
ChangeFilter = 'LOGS_CHANGE_FILTER',
ClearFilters = 'LOGS_CLEAR_FILTERS',
IncrementQueryCount = 'LOGS_INCREMENT_QUERY_COUNT',
DecrementQueryCount = 'LOGS_DECREMENT_QUERY_COUNT',
ConcatMoreLogs = 'LOGS_CONCAT_MORE_LOGS',
@ -151,6 +152,10 @@ export interface ChangeFilterAction {
}
}
export interface ClearFiltersAction {
type: ActionTypes.ClearFilters
}
export interface RemoveFilterAction {
type: ActionTypes.RemoveFilter
payload: {
@ -248,6 +253,7 @@ export type Action =
| AddFilterAction
| RemoveFilterAction
| ChangeFilterAction
| ClearFiltersAction
| DecrementQueryCountAction
| IncrementQueryCountAction
| ConcatMoreLogsAction
@ -471,6 +477,10 @@ export const addFilter = (filter: Filter): AddFilterAction => ({
payload: {filter},
})
export const clearFilters = (): ClearFiltersAction => ({
type: ActionTypes.ClearFilters,
})
export const removeFilter = (id: string): RemoveFilterAction => ({
type: ActionTypes.RemoveFilter,
payload: {id},

View File

@ -1,16 +1,69 @@
import React, {PureComponent} from 'react'
import {Filter} from 'src/types/logs'
import FilterBlock from 'src/logs/components/LogsFilter'
import {Button, ComponentSize, Radio} from 'src/reusable_ui'
interface Props {
filters: Filter[]
onDelete: (id: string) => void
onClearFilters: () => void
onFilterChange: (id: string, operator: string, value: string) => void
onUpdateTruncation: (isTruncated: boolean) => Promise<void>
isTruncated: boolean
}
class LogsFilters extends PureComponent<Props> {
public render() {
return <div className="logs-viewer--filter-bar">{this.renderFilters}</div>
return (
<div className="logs-viewer--filter-bar">
<div className="logs-viewer--filters">{this.renderFilters}</div>
{this.clearFiltersButton}
{this.truncationToggle}
</div>
)
}
private get clearFiltersButton(): JSX.Element {
const {filters, onClearFilters} = this.props
if (filters.length >= 3) {
return (
<div className="logs-viewer--clear-filters">
<Button
size={ComponentSize.Small}
text="Clear Filters"
onClick={onClearFilters}
/>
</div>
)
}
}
private get truncationToggle(): JSX.Element {
const {isTruncated, onUpdateTruncation} = this.props
return (
<Radio>
<Radio.Button
id="logs-truncation--truncate"
active={isTruncated === true}
value={true}
titleText="Truncate log messages when they exceed 1 line"
onClick={onUpdateTruncation}
>
Truncate
</Radio.Button>
<Radio.Button
id="logs-truncation--multi"
active={isTruncated === false}
value={false}
titleText="Allow log messages to wrap text"
onClick={onUpdateTruncation}
>
Wrap
</Radio.Button>
</Radio>
)
}
private get renderFilters(): JSX.Element[] {

View File

@ -12,7 +12,6 @@ import {
SeverityFormat,
LogsTableColumn,
} from 'src/types/logs'
import SlideToggle from 'src/reusable_ui/components/slide_toggle/SlideToggle'
import {DEFAULT_SEVERITY_LEVELS, SeverityLevelOptions} from 'src/logs/constants'
import {ErrorHandling} from 'src/shared/decorators/errors'
@ -26,15 +25,12 @@ interface Props {
onUpdateColumns: (columns: LogsTableColumn[]) => Promise<void>
severityFormat: SeverityFormat
onUpdateSeverityFormat: (format: SeverityFormat) => Promise<void>
onUpdateTruncation: (isTruncated: boolean) => Promise<void>
isTruncated: boolean
}
interface State {
workingLevelColumns: SeverityLevelColor[]
workingColumns: LogsTableColumn[]
workingFormat: SeverityFormat
isWorkingTruncated: boolean
}
@ErrorHandling
@ -46,14 +42,10 @@ class OptionsOverlay extends Component<Props, State> {
workingLevelColumns: this.props.severityLevelColors,
workingColumns: this.props.columns,
workingFormat: this.props.severityFormat,
isWorkingTruncated: this.props.isTruncated,
}
}
public shouldComponentUpdate(__, nextState: State) {
const isTruncatedDifferent =
nextState.isWorkingTruncated !== this.state.isWorkingTruncated
const isColorsDifferent = !_.isEqual(
nextState.workingLevelColumns,
this.state.workingLevelColumns
@ -67,12 +59,7 @@ class OptionsOverlay extends Component<Props, State> {
this.state.workingColumns
)
if (
isTruncatedDifferent ||
isColorsDifferent ||
isFormatDifferent ||
isColumnsDifferent
) {
if (isColorsDifferent || isFormatDifferent || isColumnsDifferent) {
return true
}
return false
@ -97,7 +84,6 @@ class OptionsOverlay extends Component<Props, State> {
onChangeSeverityFormat={this.handleChangeSeverityFormat}
/>
</div>
<div className="col-sm-7">{this.renderTruncateOption}</div>
<div className="col-sm-7">
<ColumnsOptions
columns={workingColumns}
@ -130,57 +116,15 @@ class OptionsOverlay extends Component<Props, State> {
)
}
private get renderTruncateOption() {
const {isWorkingTruncated} = this.state
let labelMessage = 'Truncate Log Lines'
if (isWorkingTruncated) {
labelMessage = 'Show Log Lines'
}
return (
<>
<label className="form-label">{labelMessage}</label>
<SlideToggle
onChange={this.handleTruncationToggle}
active={isWorkingTruncated}
/>
</>
)
}
private handleTruncationToggle = (): void => {
const {isWorkingTruncated} = this.state
this.setState({isWorkingTruncated: !isWorkingTruncated})
}
private get isSaveDisabled(): boolean {
const {
workingLevelColumns,
workingColumns,
workingFormat,
isWorkingTruncated,
} = this.state
const {
severityLevelColors,
columns,
severityFormat,
isTruncated,
} = this.props
const {workingLevelColumns, workingColumns, workingFormat} = this.state
const {severityLevelColors, columns, severityFormat} = this.props
const severityChanged = !_.isEqual(workingLevelColumns, severityLevelColors)
const columnsChanged = !_.isEqual(workingColumns, columns)
const formatChanged = !_.isEqual(workingFormat, severityFormat)
const isTruncatedChanged = isWorkingTruncated !== isTruncated
if (
severityChanged ||
columnsChanged ||
formatChanged ||
isTruncatedChanged
) {
if (severityChanged || columnsChanged || formatChanged) {
return false
}
@ -193,19 +137,12 @@ class OptionsOverlay extends Component<Props, State> {
onDismissOverlay,
onUpdateSeverityFormat,
onUpdateColumns,
onUpdateTruncation,
} = this.props
const {
workingLevelColumns,
workingFormat,
workingColumns,
isWorkingTruncated,
} = this.state
const {workingLevelColumns, workingFormat, workingColumns} = this.state
await onUpdateSeverityFormat(workingFormat)
await onUpdateSeverityLevels(workingLevelColumns)
await onUpdateColumns(workingColumns)
await onUpdateTruncation(isWorkingTruncated)
onDismissOverlay()
}

View File

@ -4,51 +4,44 @@
*/
.expandable--message {
display: flex;
min-height: 100%;
min-width: 100%;
align-items: center;
cursor: pointer;
cursor: zoom-in;
display: block;
.expandable--text {
> .logs-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
pointer-events: none;
}
}
.collapsed--message {
display: none;
}
.expanded--message {
display: block;
box-sizing: border-box;
position: absolute;
top: -($ix-marg-b - 1px);
left: -($ix-marg-b - 4px);
position: fixed;
white-space: pre-wrap;
padding: $ix-marg-b;
width: calc(100% + #{$ix-marg-b});
max-height: 300px;
background-color: $g5-pepper;
z-index: 100;
border: solid 2px $c-pool;
border: solid $ix-border $c-pool;
color: $g19-ghost;
border-radius: $radius;
cursor: text;
transform: translate(-$ix-border, -$ix-border);
font-size: 12px;
font-family: $code-font;
}
.expanded--dismiss {
position: absolute;
z-index: 5000;
top: -15px;
left: -10px;
width: 26px;
height: 26px;
top: 0;
left: 0;
transform: translate(-50%,-50%);
width: 24px;
height: 24px;
outline: none;
border-radius: 50%;
background-color: $c-pool;
@ -59,7 +52,7 @@
&:after {
content: '';
position: absolute;
width: 14px;
width: 13px;
height: 3px;
top: 50%;
left: 50%;

View File

@ -1,10 +1,17 @@
// Libraries
import React, {Component, MouseEvent} from 'react'
import ReactDOM from 'react-dom'
// Components
import {ClickOutside} from 'src/shared/components/ClickOutside'
import LogsMessage from 'src/logs/components/logs_message/LogsMessage'
// Types
import {NotificationAction} from 'src/types'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
interface State {
expanded: boolean
}
@ -16,49 +23,73 @@ interface Props {
searchPattern: string
}
@ErrorHandling
export class ExpandableMessage extends Component<Props, State> {
constructor(props) {
private containerRef: React.RefObject<HTMLDivElement>
constructor(props: Props) {
super(props)
this.containerRef = React.createRef()
this.state = {
expanded: false,
}
}
public render() {
const {notify, searchPattern} = this.props
const formattedValue = `${this.props.formattedValue}`
const trimmedValue = formattedValue.trimLeft()
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div onClick={this.handleClick} className="expandable--message">
<div className="expandable--text">
<LogsMessage
formattedValue={trimmedValue}
notify={notify}
searchPattern={searchPattern}
/>
</div>
<div className={this.isExpanded}>
{this.closeExpansionButton}
<LogsMessage
formattedValue={formattedValue}
notify={notify}
searchPattern={searchPattern}
/>
</div>
</div>
</ClickOutside>
<div
onClick={this.handleClick}
className="expandable--message"
ref={this.containerRef}
>
{this.message}
{this.expandedMessage}
</div>
)
}
private get isExpanded() {
private get message(): JSX.Element {
const {notify, searchPattern, formattedValue} = this.props
const valueString = `${formattedValue}`
const trimmedValue = valueString.trimLeft()
return (
<LogsMessage
formattedValue={trimmedValue}
notify={notify}
searchPattern={searchPattern}
/>
)
}
private get expandedMessage() {
const {expanded} = this.state
if (expanded) {
return 'expanded--message'
} else {
return 'collapsed--message'
if (!expanded || !this.containerRef.current) {
return null
}
const portalElement = document.getElementById('expanded-message-container')
const containerRect = this.containerRef.current.getBoundingClientRect()
const padding = 8
const style = {
top: containerRect.top - padding,
left: containerRect.left - padding,
width: containerRect.width + padding + padding,
padding,
}
const message = (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className="expanded--message" style={style}>
{this.closeExpansionButton}
{this.message}
</div>
</ClickOutside>
)
return ReactDOM.createPortal(message, portalElement)
}
private get closeExpansionButton(): JSX.Element {

View File

@ -13,8 +13,10 @@
margin-left: $ix-marg-a;
> .icon {
margin-right: $ix-marg-a;
display: inline-block;
position: relative;
top: -1px;
margin-right: $ix-marg-a;
display: inline-block;
}
.logs-message:hover & {

View File

@ -27,6 +27,7 @@ import {
addFilter,
removeFilter,
changeFilter,
clearFilters,
fetchOlderLogsAsync,
fetchNewerLogsAsync,
getLogConfigAsync,
@ -93,6 +94,7 @@ interface Props {
addFilter: (filter: Filter) => void
removeFilter: (id: string) => void
changeFilter: (id: string, operator: string, value: string) => void
clearFilters: () => void
getConfig: (url: string) => Promise<void>
updateConfig: (url: string, config: LogConfig) => Promise<void>
notify: NotificationAction
@ -231,6 +233,9 @@ class LogsPage extends Component<Props, State> {
filters={filters || []}
onDelete={this.handleFilterDelete}
onFilterChange={this.handleFilterChange}
onClearFilters={this.handleClearFilters}
onUpdateTruncation={this.handleUpdateTruncation}
isTruncated={this.isTruncated}
/>
<LogsTable
count={this.histogramTotal}
@ -259,6 +264,7 @@ class LogsPage extends Component<Props, State> {
</div>
</div>
{this.renderImportOverlay()}
{this.expandedMessageContainer}
</>
)
}
@ -603,6 +609,11 @@ class LogsPage extends Component<Props, State> {
this.fetchSearchDataset(SearchStatus.UpdatingFilters)
}
private handleClearFilters = async (): Promise<void> => {
this.props.clearFilters()
this.fetchSearchDataset(SearchStatus.UpdatingFilters)
}
private handleBarClick = (time: string): void => {
const formattedTime = moment(time).toISOString()
@ -681,8 +692,6 @@ class LogsPage extends Component<Props, State> {
onUpdateColumns={this.handleUpdateColumns}
onUpdateSeverityFormat={this.handleUpdateSeverityFormat}
severityFormat={this.severityFormat}
onUpdateTruncation={this.handleUpdateTruncation}
isTruncated={this.isTruncated}
/>
</OverlayTechnology>
)
@ -742,6 +751,15 @@ class LogsPage extends Component<Props, State> {
private get isTruncated(): boolean {
return this.props.logConfig.isTruncated
}
private get expandedMessageContainer(): JSX.Element {
return (
<div
className="logs-viewer--expanded-message-container"
id="expanded-message-container"
/>
)
}
}
const mapStateToProps = ({
@ -792,6 +810,7 @@ const mapDispatchToProps = {
addFilter,
removeFilter,
changeFilter,
clearFilters,
fetchOlderLogsAsync,
fetchNewerLogsAsync,
setTableCustomTime: setTableCustomTimeAsync,

View File

@ -80,6 +80,10 @@ const addFilter = (state: LogsState, action: AddFilterAction): LogsState => {
return {...state, filters: [..._.get(state, 'filters', []), filter]}
}
const clearFilters = (state: LogsState): LogsState => {
return {...state, filters: []}
}
const changeFilter = (
state: LogsState,
action: ChangeFilterAction
@ -230,6 +234,8 @@ export default (state: LogsState = defaultState, action: Action) => {
return removeFilter(state, action)
case ActionTypes.ChangeFilter:
return changeFilter(state, action)
case ActionTypes.ClearFilters:
return clearFilters(state)
case ActionTypes.IncrementQueryCount:
return incrementQueryCount(state, action)
case ActionTypes.DecrementQueryCount:

View File

@ -5,7 +5,7 @@
$logs-viewer-graph-height: 170px;
$logs-viewer-search-height: 46px;
$logs-viewer-filter-height: 42px;
$logs-viewer-filter-height: 46px;
$logs-viewer-results-text-indent: 33px;
$logs-viewer-gutter: 60px;
@ -75,6 +75,26 @@ $logs-viewer-gutter: 60px;
background-color: $g3-castle;
}
.logs-viewer--filters {
overflow: hidden;
position: relative;
&:after {
content: '';
top: 0;
right: 0;
height: 100%;
width: 44px;
pointer-events: none;
position: absolute;
@include gradient-h(rgba($g3-castle, 0), $g3-castle);
}
}
.logs-viewer--clear-filters {
padding: 0 $ix-marg-b;
}
.logs-viewer--results-text {
margin: 0;
margin-right: $logs-viewer-results-text-indent;
@ -137,6 +157,7 @@ $logs-viewer-gutter: 60px;
font-weight: 500;
font-family: $code-font;
margin: 1px;
max-width: 289px;
&.active,
&:hover {
@ -146,6 +167,13 @@ $logs-viewer-gutter: 60px;
&:hover {
cursor: pointer;
}
> span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
}
.logs-viewer--filter-remove {