diff --git a/ui/src/logs/components/LogViewerHeader.tsx b/ui/src/logs/components/LogViewerHeader.tsx index 33c72ef205..4ab7f5ebe9 100644 --- a/ui/src/logs/components/LogViewerHeader.tsx +++ b/ui/src/logs/components/LogViewerHeader.tsx @@ -26,24 +26,32 @@ class LogViewerHeader extends PureComponent { public render(): JSX.Element { const {timeRange} = this.props return ( - <> - - - - +
+
+
+

Log Viewer

+
+
+ + + +
+
+
) } diff --git a/ui/src/logs/components/LogsFilter.tsx b/ui/src/logs/components/LogsFilter.tsx new file mode 100644 index 0000000000..686ad49667 --- /dev/null +++ b/ui/src/logs/components/LogsFilter.tsx @@ -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 { + constructor(props: Props) { + super(props) + + this.state = { + expanded: false, + } + } + + public render() { + const { + filter: {id}, + onDelete, + } = this.props + const {expanded} = this.state + + return ( +
  • + {this.label} +
    + {expanded && this.renderTooltip} +
  • + ) + } + + private get label(): JSX.Element { + const { + filter: {key, operator, value}, + } = this.props + + return ( + {`${key} ${operator} ${value}`} + ) + } + + 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 ( +
      +
    • {toggleStatusText}
    • +
    • {toggleOperatorText}
    • +
    • Delete
    • +
    + ) + } +} + +export default LogsFilter diff --git a/ui/src/logs/components/LogsFilterBar.tsx b/ui/src/logs/components/LogsFilterBar.tsx new file mode 100644 index 0000000000..64feb89acd --- /dev/null +++ b/ui/src/logs/components/LogsFilterBar.tsx @@ -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 { + public render() { + const {numResults} = this.props + + return ( +
    + +
      {this.renderFilters}
    +
    + ) + } + + private get renderFilters(): JSX.Element[] { + const {filters} = this.props + + return filters.map(filter => ( + + )) + } + + 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 diff --git a/ui/src/logs/components/LogsGraphContainer.tsx b/ui/src/logs/components/LogsGraph.tsx similarity index 100% rename from ui/src/logs/components/LogsGraphContainer.tsx rename to ui/src/logs/components/LogsGraph.tsx diff --git a/ui/src/logs/components/LogsSearchBar.tsx b/ui/src/logs/components/LogsSearchBar.tsx new file mode 100644 index 0000000000..fdfa233357 --- /dev/null +++ b/ui/src/logs/components/LogsSearchBar.tsx @@ -0,0 +1,43 @@ +import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react' + +interface Props { + searchString: string + onChange: (e: ChangeEvent) => void + onSearch: () => void +} + +class LogsSearchBar extends PureComponent { + public render() { + const {searchString, onSearch, onChange} = this.props + + return ( +
    +
    + + +
    + +
    + ) + } + + private handleInputKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Enter') { + return this.props.onSearch() + } + } +} + +export default LogsSearchBar diff --git a/ui/src/logs/components/LogsTable.tsx b/ui/src/logs/components/LogsTable.tsx new file mode 100644 index 0000000000..ef6d52efe5 --- /dev/null +++ b/ui/src/logs/components/LogsTable.tsx @@ -0,0 +1,17 @@ +import React, {PureComponent} from 'react' + +interface Props { + thing: string +} + +class LogsTableContainer extends PureComponent { + public render() { + return ( +
    +

    {this.props.thing}

    +
    + ) + } +} + +export default LogsTableContainer diff --git a/ui/src/logs/components/LogsTableContainer.tsx b/ui/src/logs/components/LogsTableContainer.tsx deleted file mode 100644 index 6388be7dba..0000000000 --- a/ui/src/logs/components/LogsTableContainer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, {PureComponent} from 'react' - -interface Props { - thing: string -} - -class LogsTableContainer extends PureComponent { - public render() { - return ( - <> -
    -

    search

    -
    -
    -

    {this.props.thing}

    -
    - - ) - } -} - -export default LogsTableContainer diff --git a/ui/src/logs/components/TimeRangeDropdown.tsx b/ui/src/logs/components/TimeRangeDropdown.tsx index 7aa1b27d87..c3ad299f19 100644 --- a/ui/src/logs/components/TimeRangeDropdown.tsx +++ b/ui/src/logs/components/TimeRangeDropdown.tsx @@ -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 { public render() { const {selected, preventCustomTimeRange, page} = this.props - const {customTimeRange, isCustomTimeRangeOpen, isOpen} = this.state + const {customTimeRange, isCustomTimeRangeOpen} = this.state return (
    -
    +
    { ) } + 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()') { diff --git a/ui/src/logs/containers/LogsPage.tsx b/ui/src/logs/containers/LogsPage.tsx index ae08d4489b..6ef3656d8b 100644 --- a/ui/src/logs/containers/LogsPage.tsx +++ b/ui/src/logs/containers/LogsPage.tsx @@ -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 { +interface State { + searchString: string + filters: Filter[] +} + +const DUMMY_FILTERS = [ + { + id: '0', + key: 'host', + value: 'prod1-rsavage.local', + operator: '==', + enabled: true, + }, +] + +class LogsPage extends PureComponent { + 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 { } public render() { + const {searchString, filters} = this.state + return (
    -
    -
    -
    -

    Log Viewer

    -
    -
    {this.header}
    -
    -
    + {this.header}
    - {this.chart} - + {this.chart} + + + ) @@ -94,6 +133,20 @@ class LogsPage extends PureComponent { ) } + private handleSearchInputChange = ( + e: ChangeEvent + ): 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() diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 5e0d8a6481..06e9138dd5 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -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; diff --git a/ui/src/style/modules/mixins.scss b/ui/src/style/modules/mixins.scss index 1723b10db5..196ed1628a 100644 --- a/ui/src/style/modules/mixins.scss +++ b/ui/src/style/modules/mixins.scss @@ -125,3 +125,8 @@ $scrollbar-offset: 3px; cursor: default; } } + +// Shadows +%drop-shadow { + box-shadow: 0 0 10px 2px $g2-kevlar; +} diff --git a/ui/src/style/pages/logs-viewer.scss b/ui/src/style/pages/logs-viewer.scss index 70d95d6687..18febbbedb 100644 --- a/ui/src/style/pages/logs-viewer.scss +++ b/ui/src/style/pages/logs-viewer.scss @@ -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%;