Merge pull request #3529 from influxdata/log-viewer/placeholder-markup

Log Viewer Search & Filtering UI
pull/10616/head
Alex Paxton 2018-05-29 11:05:16 -07:00 committed by GitHub
commit 955761ec3b
12 changed files with 523 additions and 66 deletions

View File

@ -26,7 +26,12 @@ class LogViewerHeader extends PureComponent<Props> {
public render(): JSX.Element {
const {timeRange} = this.props
return (
<>
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">Log Viewer</h1>
</div>
<div className="page-header__right">
<Dropdown
className="dropdown-300"
items={this.sourceDropDownItems}
@ -34,7 +39,8 @@ class LogViewerHeader extends PureComponent<Props> {
onChoose={this.handleChooseSource}
/>
<Dropdown
className="dropdown-300"
className="dropdown-180"
iconName="disks"
items={this.namespaceDropDownItems}
selected={this.selectedNamespace}
onChoose={this.handleChooseNamespace}
@ -43,7 +49,9 @@ class LogViewerHeader extends PureComponent<Props> {
onChooseTimeRange={this.handleChooseTimeRange}
selected={timeRange}
/>
</>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,94 @@
import React, {PureComponent} from 'react'
import classnames from 'classnames'
import {Filter} from 'src/logs/containers/LogsPage'
interface Props {
filter: Filter
onDelete: (id: string) => () => void
onToggleStatus: (id: string) => () => void
onToggleOperator: (id: string) => () => void
}
interface State {
expanded: boolean
}
class LogsFilter extends PureComponent<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
expanded: false,
}
}
public render() {
const {
filter: {id},
onDelete,
} = this.props
const {expanded} = this.state
return (
<li className={this.className} onMouseLeave={this.handleMouseLeave}>
{this.label}
<div className="logs-viewer--filter-remove" onClick={onDelete(id)} />
{expanded && this.renderTooltip}
</li>
)
}
private get label(): JSX.Element {
const {
filter: {key, operator, value},
} = this.props
return (
<span
onMouseEnter={this.handleMouseEnter}
>{`${key} ${operator} ${value}`}</span>
)
}
private get className(): string {
const {expanded} = this.state
const {
filter: {enabled},
} = this.props
return classnames('logs-viewer--filter', {
active: expanded,
disabled: !enabled,
})
}
private handleMouseEnter = (): void => {
this.setState({expanded: true})
}
private handleMouseLeave = (): void => {
this.setState({expanded: false})
}
private get renderTooltip(): JSX.Element {
const {
filter: {id, enabled, operator},
onDelete,
onToggleStatus,
onToggleOperator,
} = this.props
const toggleStatusText = enabled ? 'Disable' : 'Enable'
const toggleOperatorText = operator === '==' ? '!=' : '=='
return (
<ul className="logs-viewer--filter-tooltip">
<li onClick={onToggleStatus(id)}>{toggleStatusText}</li>
<li onClick={onToggleOperator(id)}>{toggleOperatorText}</li>
<li onClick={onDelete(id)}>Delete</li>
</ul>
)
}
}
export default LogsFilter

View File

@ -0,0 +1,84 @@
import React, {PureComponent} from 'react'
import {Filter} from 'src/logs/containers/LogsPage'
import FilterBlock from 'src/logs/components/LogsFilter'
interface Props {
numResults: number
filters: Filter[]
onUpdateFilters: (fitlers: Filter[]) => void
}
class LogsFilters extends PureComponent<Props> {
public render() {
const {numResults} = this.props
return (
<div className="logs-viewer--filter-bar">
<label className="logs-viewer--results-text">
Query returned <strong>{numResults} Events</strong>
</label>
<ul className="logs-viewer--filters">{this.renderFilters}</ul>
</div>
)
}
private get renderFilters(): JSX.Element[] {
const {filters} = this.props
return filters.map(filter => (
<FilterBlock
key={filter.id}
filter={filter}
onDelete={this.handleDeleteFilter}
onToggleStatus={this.handleToggleFilterStatus}
onToggleOperator={this.handleToggleFilterOperator}
/>
))
}
private handleDeleteFilter = (id: string) => (): void => {
const {filters, onUpdateFilters} = this.props
const filteredFilters = filters.filter(filter => filter.id !== id)
onUpdateFilters(filteredFilters)
}
private handleToggleFilterStatus = (id: string) => (): void => {
const {filters, onUpdateFilters} = this.props
const filteredFilters = filters.map(filter => {
if (filter.id === id) {
return {...filter, enabled: !filter.enabled}
}
return filter
})
onUpdateFilters(filteredFilters)
}
private handleToggleFilterOperator = (id: string) => (): void => {
const {filters, onUpdateFilters} = this.props
const filteredFilters = filters.map(filter => {
if (filter.id === id) {
return {...filter, operator: this.toggleOperator(filter.operator)}
}
return filter
})
onUpdateFilters(filteredFilters)
}
private toggleOperator = (op: string): string => {
if (op === '==') {
return '!='
}
return '=='
}
}
export default LogsFilters

View File

@ -0,0 +1,43 @@
import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react'
interface Props {
searchString: string
onChange: (e: ChangeEvent<HTMLInputElement>) => void
onSearch: () => void
}
class LogsSearchBar extends PureComponent<Props> {
public render() {
const {searchString, onSearch, onChange} = this.props
return (
<div className="logs-viewer--search-bar">
<div className="logs-viewer--search-input">
<input
type="text"
placeholder="Search logs using Keywords or Regular Expressions..."
value={searchString}
onChange={onChange}
onKeyDown={this.handleInputKeyDown}
className="form-control input-sm"
spellCheck={false}
autoComplete="off"
/>
<span className="icon search" />
</div>
<button className="btn btn-sm btn-primary" onClick={onSearch}>
<span className="icon search" />
Search
</button>
</div>
)
}
private handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
return this.props.onSearch()
}
}
}
export default LogsSearchBar

View File

@ -0,0 +1,17 @@
import React, {PureComponent} from 'react'
interface Props {
thing: string
}
class LogsTableContainer extends PureComponent<Props> {
public render() {
return (
<div className="logs-viewer--table-container">
<p>{this.props.thing}</p>
</div>
)
}
}
export default LogsTableContainer

View File

@ -1,22 +0,0 @@
import React, {PureComponent} from 'react'
interface Props {
thing: string
}
class LogsTableContainer extends PureComponent<Props> {
public render() {
return (
<>
<div className="logs-viewer--search-container">
<p>search</p>
</div>
<div className="logs-viewer--table-container">
<p>{this.props.thing}</p>
</div>
</>
)
}
}
export default LogsTableContainer

View File

@ -1,6 +1,7 @@
import React, {Component} from 'react'
import classnames from 'classnames'
import moment from 'moment'
import _ from 'lodash'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
import timeRanges from 'src/logs/data/timeRanges'
@ -56,16 +57,12 @@ class TimeRangeDropdown extends Component<Props, State> {
public render() {
const {selected, preventCustomTimeRange, page} = this.props
const {customTimeRange, isCustomTimeRangeOpen, isOpen} = this.state
const {customTimeRange, isCustomTimeRangeOpen} = this.state
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className="time-range-dropdown">
<div
className={classnames('dropdown dropdown-290', {
open: isOpen,
})}
>
<div className={this.dropdownClassName}>
<div
className="btn btn-sm btn-default dropdown-toggle"
onClick={this.toggleMenu}
@ -132,6 +129,21 @@ class TimeRangeDropdown extends Component<Props, State> {
)
}
private get dropdownClassName(): string {
const {
isOpen,
customTimeRange: {lower, upper},
} = this.state
const absoluteTimeRange = !_.isEmpty(lower) && !_.isEmpty(upper)
return classnames('dropdown', {
'dropdown-290': absoluteTimeRange,
'dropdown-120': !absoluteTimeRange,
open: isOpen,
})
}
private findTimeRangeInputValue = ({upper, lower}: TimeRange) => {
if (upper && lower) {
if (upper === 'now()') {

View File

@ -1,4 +1,4 @@
import React, {PureComponent} from 'react'
import React, {PureComponent, ChangeEvent} from 'react'
import {connect} from 'react-redux'
import {
getSourceAndPopulateNamespacesAsync,
@ -9,12 +9,22 @@ import {
} from 'src/logs/actions'
import {getSourcesAsync} from 'src/shared/actions/sources'
import LogViewerHeader from 'src/logs/components/LogViewerHeader'
import Graph from 'src/logs/components/LogsGraph'
import Table from 'src/logs/components/LogsTable'
import SearchBar from 'src/logs/components/LogsSearchBar'
import FilterBar from 'src/logs/components/LogsFilterBar'
import LogViewerChart from 'src/logs/components/LogViewerChart'
import GraphContainer from 'src/logs/components/LogsGraphContainer'
import TableContainer from 'src/logs/components/LogsTableContainer'
import {Source, Namespace, TimeRange} from 'src/types'
export interface Filter {
id: string
key: string
value: string
operator: string
enabled: boolean
}
interface Props {
sources: Source[]
currentSource: Source | null
@ -30,7 +40,31 @@ interface Props {
histogramData: object[]
}
class LogsPage extends PureComponent<Props> {
interface State {
searchString: string
filters: Filter[]
}
const DUMMY_FILTERS = [
{
id: '0',
key: 'host',
value: 'prod1-rsavage.local',
operator: '==',
enabled: true,
},
]
class LogsPage extends PureComponent<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
searchString: '',
filters: DUMMY_FILTERS,
}
}
public componentDidUpdate() {
if (!this.props.currentSource) {
this.props.getSource(this.props.sources[0].id)
@ -42,19 +76,24 @@ class LogsPage extends PureComponent<Props> {
}
public render() {
const {searchString, filters} = this.state
return (
<div className="page">
<div className="page-header full-width">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">Log Viewer</h1>
</div>
<div className="page-header__right">{this.header}</div>
</div>
</div>
{this.header}
<div className="page-contents logs-viewer">
<GraphContainer>{this.chart}</GraphContainer>
<TableContainer thing="snooo" />
<Graph>{this.chart}</Graph>
<SearchBar
searchString={searchString}
onChange={this.handleSearchInputChange}
onSearch={this.handleSubmitSearch}
/>
<FilterBar
numResults={300}
filters={filters}
onUpdateFilters={this.handleUpdateFilters}
/>
<Table thing="snooo" />
</div>
</div>
)
@ -94,6 +133,20 @@ class LogsPage extends PureComponent<Props> {
)
}
private handleSearchInputChange = (
e: ChangeEvent<HTMLInputElement>
): void => {
this.setState({searchString: e.target.value})
}
private handleSubmitSearch = (): void => {
// do the thing
}
private handleUpdateFilters = (filters: Filter[]): void => {
this.setState({filters})
}
private handleChooseTimerange = (timeRange: TimeRange) => {
this.props.setTimeRangeAsync(timeRange)
this.props.executeHistogramQueryAsync()

View File

@ -174,7 +174,7 @@
min-width: 350px;
user-select: text;
transform: translateX(-50%);
box-shadow: 0 0 10px 2px $g2-kevlar;
@extend %drop-shadow;
&.hidden {
display: none !important;

View File

@ -125,3 +125,8 @@ $scrollbar-offset: 3px;
cursor: default;
}
}
// Shadows
%drop-shadow {
box-shadow: 0 0 10px 2px $g2-kevlar;
}

View File

@ -4,14 +4,15 @@
*/
$logs-viewer-graph-height: 240px;
$logs-viewer-search-height: 108px;
$logs-viewer-search-height: 46px;
$logs-viewer-filter-height: 42px;
$logs-viewer-gutter: 60px;
.logs-viewer {
display: flex;
flex-direction: column;
align-items: stretch;
flex-wrap: none;
flex-wrap: nowrap;
}
.logs-viewer--graph-container {
@ -21,18 +22,180 @@ $logs-viewer-gutter: 60px;
display: flex;
}
.logs-viewer--search-container {
padding: 20px $logs-viewer-gutter;
.logs-viewer--search-bar {
display: flex;
align-items: flex-end;
flex-wrap: nowrap;
padding: 0 $logs-viewer-gutter;
height: $logs-viewer-search-height;
background-color: $g3-castle;
}
.logs-viewer--table-container {
padding: 12px $logs-viewer-gutter 30px $logs-viewer-gutter;
height: calc(100% - #{$logs-viewer-graph-height + $logs-viewer-search-height});
height: calc(100% - #{$logs-viewer-graph-height + $logs-viewer-search-height + $logs-viewer-filter-height});
background-color: $g3-castle;
}
// Search Bar
.logs-viewer--search-input {
flex: 1 0 0;
margin-right: 8px;
position: relative;
> span.icon.search {
font-size: 14px;
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
color: $g8-storm;
transition: color 0.25s ease;
}
> input.form-control.input-sm {
padding-left: 30px;
}
> input.form-control.input-sm:focus + span.icon.search {
color: $c-pool;
}
}
// Filters Bar
.logs-viewer--filter-bar {
display: flex;
align-items: center;
@include no-user-select();
padding: 0 $logs-viewer-gutter;
height: $logs-viewer-filter-height;
background-color: $g3-castle;
}
.logs-viewer--results-text {
margin: 0 12px 0 33px;
padding: 0;
font-size: 13px;
line-height: 13px;
font-weight: 500;
color: $g9-mountain;
strong {
color: $g15-platinum;
font-weight: 700;
}
}
.logs-viewer--filters {
flex: 1 0 0;
margin: 0;
padding: 0;
display: flex;
align-items: center;
}
.logs-viewer--filter {
position: relative;
font-size: 12px;
display: flex;
align-items: center;
list-style: none;
padding: 0 2px 0 8px;
height: 26px;
border-radius: 4px;
background-color: $g5-pepper;
color: $g13-mist;
font-weight: 600;
margin: 2px;
&.disabled {
background-color: $g4-onyx;
color: $g9-mountain;
font-style: italic;
}
&.active {
background-color: $g6-smoke;
color: $g15-platinum;
}
}
.logs-viewer--filter-remove {
outline: none;
width: 24px;
height: 24px;
background-color: transparent;
border: 0;
position: relative;
&:before,
&:after {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 2px;
border-radius: 1px;
background-color: $g8-storm;
transition: background-color 0.25s ease;
content: '';
}
&:before {
transform: translate(-50%, -50%) rotate(-45deg);
}
&:after {
transform: translate(-50%, -50%) rotate(45deg);
}
&:hover {
cursor: pointer;
&:before,
&:after {
background-color: $c-dreamsicle;
}
}
}
.logs-viewer--filter-tooltip {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: $radius;
z-index: 9999;
@extend %drop-shadow;
background-color: $g4-onyx;
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
> li {
height: 26px;
line-height: 26px;
padding: 0 8px;
font-size: 13px;
font-weight: 600;
color: $g11-sidewalk;
transition: background-color 0.25s ease, color 0.25s ease;
margin: 0;
&:hover {
cursor: pointer;
background-color: $g5-pepper;
color: $g18-cloud;
}
}
}
// Graph
.logs-viewer--graph {
position: relative;
width: 100%;