diff --git a/ui/src/logs/actions/index.ts b/ui/src/logs/actions/index.ts index 20191c604..09020de7d 100644 --- a/ui/src/logs/actions/index.ts +++ b/ui/src/logs/actions/index.ts @@ -10,7 +10,7 @@ import { import {getDeep} from 'src/utils/wrappers' import buildQuery from 'src/utils/influxql' import {executeQueryAsync} from 'src/logs/api' -import {LogsState} from 'src/types/localStorage' +import {LogsState, Filter} from 'src/types/logs' interface TableData { columns: string[] @@ -49,8 +49,33 @@ export enum ActionTypes { SetTableData = 'LOGS_SET_TABLE_DATA', ChangeZoom = 'LOGS_CHANGE_ZOOM', SetSearchTerm = 'LOGS_SET_SEARCH_TERM', + AddFilter = 'LOGS_ADD_FILTER', + RemoveFilter = 'LOGS_REMOVE_FILTER', + ChangeFilter = 'LOGS_CHANGE_FILTER', } +export interface AddFilterAction { + type: ActionTypes.AddFilter + payload: { + filter: Filter + } +} + +export interface ChangeFilterAction { + type: ActionTypes.ChangeFilter + payload: { + id: string + operator: string + value: string + } +} + +export interface RemoveFilterAction { + type: ActionTypes.RemoveFilter + payload: { + id: string + } +} interface SetSourceAction { type: ActionTypes.SetSource payload: { @@ -133,6 +158,9 @@ export type Action = | SetTableData | SetTableQueryConfig | SetSearchTerm + | AddFilterAction + | RemoveFilterAction + | ChangeFilterAction const getTimeRange = (state: State): TimeRange | null => getDeep(state, 'logs.timeRange', null) @@ -152,11 +180,29 @@ const getTableQueryConfig = (state: State): QueryConfig | null => const getSearchTerm = (state: State): string | null => getDeep(state, 'logs.searchTerm', null) +const getFilters = (state: State): Filter[] => + getDeep(state, 'logs.filters', []) + +export const changeFilter = (id: string, operator: string, value: string) => ({ + type: ActionTypes.ChangeFilter, + payload: {id, operator, value}, +}) + export const setSource = (source: Source): SetSourceAction => ({ type: ActionTypes.SetSource, payload: {source}, }) +export const addFilter = (filter: Filter): AddFilterAction => ({ + type: ActionTypes.AddFilter, + payload: {filter}, +}) + +export const removeFilter = (id: string): RemoveFilterAction => ({ + type: ActionTypes.RemoveFilter, + payload: {id}, +}) + const setHistogramData = (response): SetHistogramData => ({ type: ActionTypes.SetHistogramData, payload: {data: [{response}]}, @@ -173,9 +219,10 @@ export const executeHistogramQueryAsync = () => async ( const namespace = getNamespace(state) const proxyLink = getProxyLink(state) const searchTerm = getSearchTerm(state) + const filters = getFilters(state) if (_.every([queryConfig, timeRange, namespace, proxyLink])) { - const query = buildLogQuery(timeRange, queryConfig, searchTerm) + const query = buildLogQuery(timeRange, queryConfig, filters, searchTerm) const response = await executeQueryAsync(proxyLink, namespace, query) dispatch(setHistogramData(response)) @@ -198,9 +245,10 @@ export const executeTableQueryAsync = () => async ( const namespace = getNamespace(state) const proxyLink = getProxyLink(state) const searchTerm = getSearchTerm(state) + const filters = getFilters(state) if (_.every([queryConfig, timeRange, namespace, proxyLink])) { - const query = buildLogQuery(timeRange, queryConfig, searchTerm) + const query = buildLogQuery(timeRange, queryConfig, filters, searchTerm) const response = await executeQueryAsync(proxyLink, namespace, query) const series = getDeep(response, 'results.0.series.0', defaultTableData) diff --git a/ui/src/logs/components/LogsFilter.tsx b/ui/src/logs/components/LogsFilter.tsx index 8a4d1edff..c17b8814e 100644 --- a/ui/src/logs/components/LogsFilter.tsx +++ b/ui/src/logs/components/LogsFilter.tsx @@ -1,17 +1,19 @@ import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react' import classnames from 'classnames' -import {Filter} from 'src/logs/containers/LogsPage' +import {Filter} from 'src/types/logs' +import {getDeep} from 'src/utils/wrappers' import {ClickOutside} from 'src/shared/components/ClickOutside' interface Props { filter: Filter - onDelete: (id: string) => () => void - onChangeOperator: (id: string, newOperator: string) => void - onChangeValue: (id: string, newValue: string) => void + onDelete: (id: string) => void + onChangeFilter: (id: string, newOperator: string, newValue: string) => void } interface State { editing: boolean + value: string + operator: string } class LogsFilter extends PureComponent { @@ -20,28 +22,29 @@ class LogsFilter extends PureComponent { this.state = { editing: false, + value: this.props.filter.value, + operator: this.props.filter.operator, } } public render() { - const { - filter: {id}, - onDelete, - } = this.props const {editing} = this.state return (
  • {editing ? this.renderEditor : this.label} -
    +
  • ) } private handleClickOutside = (): void => { - this.setState({editing: false}) + this.stopEditing() } private handleStartEdit = (): void => { @@ -53,17 +56,28 @@ class LogsFilter extends PureComponent { return classnames('logs-viewer--filter', {active: editing}) } + private handleDelete = () => { + const id = getDeep(this.props, 'filter.id', '') + this.props.onDelete(id) + } + private get label(): JSX.Element { const { filter: {key, operator, value}, } = this.props - return {`${key} ${operator} ${value}`} + let displayKey = key + if (key === 'severity_1') { + displayKey = 'severity' + } + + return {`${displayKey} ${operator} ${value}`} } private get renderEditor(): JSX.Element { + const {operator, value} = this.state const { - filter: {key, operator, value}, + filter: {key}, } = this.props return ( @@ -92,38 +106,37 @@ class LogsFilter extends PureComponent { } private handleOperatorInput = (e: ChangeEvent): void => { - const { - filter: {id}, - onChangeOperator, - } = this.props + const operator = getDeep(e, 'target.value', '').trim() - const cleanValue = this.enforceOperatorChars(e.target.value) - - onChangeOperator(id, cleanValue) + this.setState({operator}) } private handleValueInput = (e: ChangeEvent): void => { - const { - filter: {id}, - onChangeValue, - } = this.props - - onChangeValue(id, e.target.value) - } - - private enforceOperatorChars = text => { - return text - .split('') - .filter(t => ['!', '~', `=`].includes(t)) - .join('') + const value = getDeep(e, 'target.value', '').trim() + this.setState({value}) } private handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Enter') { e.preventDefault() - this.setState({editing: false}) + this.stopEditing() } } + + private stopEditing(): void { + const id = getDeep(this.props, 'filter.id', '') + const {operator, value} = this.state + + let state = {} + if (['!=', '==', '=~'].includes(operator) && value !== '') { + this.props.onChangeFilter(id, operator, value) + } else { + const {filter} = this.props + state = {operator: filter.operator, value: filter.value} + } + + this.setState({...state, editing: false}) + } } export default LogsFilter diff --git a/ui/src/logs/components/LogsFilterBar.tsx b/ui/src/logs/components/LogsFilterBar.tsx index 9b6223caf..244d4e0d4 100644 --- a/ui/src/logs/components/LogsFilterBar.tsx +++ b/ui/src/logs/components/LogsFilterBar.tsx @@ -1,11 +1,12 @@ import React, {PureComponent} from 'react' -import {Filter} from 'src/logs/containers/LogsPage' +import {Filter} from 'src/types/logs' import FilterBlock from 'src/logs/components/LogsFilter' interface Props { numResults: number filters: Filter[] - onUpdateFilters: (fitlers: Filter[]) => void + onDelete: (id: string) => void + onFilterChange: (id: string, operator: string, value: string) => void } class LogsFilters extends PureComponent { @@ -29,48 +30,11 @@ class LogsFilters extends PureComponent { )) } - - private handleDeleteFilter = (id: string) => (): void => { - const {filters, onUpdateFilters} = this.props - - const filteredFilters = filters.filter(filter => filter.id !== id) - - onUpdateFilters(filteredFilters) - } - - private handleChangeFilterOperator = (id: string, operator: string): void => { - const {filters, onUpdateFilters} = this.props - - const filteredFilters = filters.map(filter => { - if (filter.id === id) { - return {...filter, operator} - } - - return filter - }) - - onUpdateFilters(filteredFilters) - } - - private handleChangeFilterValue = (id: string, value: string): void => { - const {filters, onUpdateFilters} = this.props - - const filteredFilters = filters.map(filter => { - if (filter.id === id) { - return {...filter, value} - } - - return filter - }) - - onUpdateFilters(filteredFilters) - } } export default LogsFilters diff --git a/ui/src/logs/components/LogsTable.tsx b/ui/src/logs/components/LogsTable.tsx index 6a74c7858..d761a3c20 100644 --- a/ui/src/logs/components/LogsTable.tsx +++ b/ui/src/logs/components/LogsTable.tsx @@ -7,6 +7,8 @@ import {getDeep} from 'src/utils/wrappers' import FancyScrollbar from 'src/shared/components/FancyScrollbar' const ROW_HEIGHT = 26 +const ROW_CHAR_LIMIT = 100 +const CHAR_WIDTH = 7 interface Props { data: { @@ -16,6 +18,7 @@ interface Props { isScrolledToTop: boolean onScrollVertical: () => void onScrolledToTop: () => void + onTagSelection: (selection: {tag: string; key: string}) => void } interface State { @@ -26,23 +29,29 @@ interface State { class LogsTable extends Component { public static getDerivedStateFromProps(props, state) { - const {scrolledToTop} = props + const {isScrolledToTop} = props let scrollTop = _.get(state, 'scrollTop', 0) - if (scrolledToTop) { + if (isScrolledToTop) { scrollTop = 0 } + const scrollLeft = _.get(state, 'scrollLeft', 0) + return { scrollTop, - scrollLeft: 0, + scrollLeft, currentRow: -1, } } + private grid: React.RefObject + constructor(props: Props) { super(props) + this.grid = React.createRef() + this.state = { scrollTop: 0, scrollLeft: 0, @@ -50,6 +59,10 @@ class LogsTable extends Component { } } + public componentDidUpdate() { + this.grid.current.recomputeGridSize() + } + public render() { const rowCount = getDeep(this.props, 'data.values.length', 0) const columnCount = getDeep(this.props, 'data.columns.length', 1) - 1 @@ -57,7 +70,7 @@ class LogsTable extends Component { return (
    {({width}) => ( @@ -84,11 +97,11 @@ class LogsTable extends Component { }} setScrollTop={this.handleScrollbarScroll} scrollTop={this.state.scrollTop} - autoHide={true} + autoHide={false} > { cellRenderer={this.cellRenderer} columnCount={columnCount} columnWidth={this.getColumnWidth} - style={{height: ROW_HEIGHT * rowCount}} + ref={this.grid} + style={{height: this.calculateTotalHeight()}} /> )} @@ -113,6 +127,31 @@ class LogsTable extends Component { this.handleScroll(target) } + private calculateMessageHeight = (index: number): number => { + const columnIndex = this.props.data.columns.indexOf('message') + const height = + (Math.floor( + this.props.data.values[index][columnIndex].length / ROW_CHAR_LIMIT + ) + + 1) * + ROW_HEIGHT + return height + } + + private calculateTotalHeight = (): number => { + return _.reduce( + this.props.data.values, + (acc, __, index) => { + return acc + this.calculateMessageHeight(index) + }, + 0 + ) + } + + private calculateRowHeight = (d: {index: number}): number => { + return this.calculateMessageHeight(d.index) + } + private handleScroll = scrollInfo => { const {scrollLeft, scrollTop} = scrollInfo @@ -147,7 +186,7 @@ class LogsTable extends Component { switch (column) { case 'message': - return 1200 + return ROW_CHAR_LIMIT * CHAR_WIDTH case 'timestamp': return 160 case 'procid': @@ -219,24 +258,39 @@ class LogsTable extends Component {
    ) break - default: - value = ( -
    - {value} -
    - ) } const highlightRow = rowIndex === this.state.currentRow && columnIndex >= 0 + if (this.isClickable(column)) { + return ( +
    +
    + {value} +
    +
    + ) + } + return (
    { this.setState({currentRow: +target.dataset.index}) } - private handleMouseLeave = (): void => { + private handleTagClick = (e: MouseEvent) => { + const {onTagSelection} = this.props + const target = e.target as HTMLElement + const selection = { + tag: target.dataset.tagValue, + key: target.dataset.tagKey, + } + + onTagSelection(selection) + } + + private handleMouseOut = () => { this.setState({currentRow: -1}) } + + private isClickable(key): boolean { + return _.includes( + ['appname', 'facility', 'host', 'hostname', 'severity_1'], + key + ) + } } export default LogsTable diff --git a/ui/src/logs/containers/LogsPage.tsx b/ui/src/logs/containers/LogsPage.tsx index 55b1f900a..5d47c4f53 100644 --- a/ui/src/logs/containers/LogsPage.tsx +++ b/ui/src/logs/containers/LogsPage.tsx @@ -1,4 +1,5 @@ import React, {PureComponent} from 'react' +import uuid from 'uuid' import {connect} from 'react-redux' import { getSourceAndPopulateNamespacesAsync, @@ -7,6 +8,9 @@ import { executeQueriesAsync, changeZoomAsync, setSearchTermAsync, + addFilter, + removeFilter, + changeFilter, } from 'src/logs/actions' import {getSourcesAsync} from 'src/shared/actions/sources' import LogViewerHeader from 'src/logs/components/LogViewerHeader' @@ -18,13 +22,7 @@ import LogsTable from 'src/logs/components/LogsTable' import {getDeep} from 'src/utils/wrappers' import {Source, Namespace, TimeRange} from 'src/types' - -export interface Filter { - id: string - key: string - value: string - operator: string -} +import {Filter} from 'src/types/logs' interface Props { sources: Source[] @@ -38,6 +36,9 @@ interface Props { changeZoomAsync: (timeRange: TimeRange) => void executeQueriesAsync: () => void setSearchTermAsync: (searchTerm: string) => void + addFilter: (filter: Filter) => void + removeFilter: (id: string) => void + changeFilter: (id: string, operator: string, value: string) => void timeRange: TimeRange histogramData: object[] tableData: { @@ -45,23 +46,14 @@ interface Props { values: string[] } searchTerm: string + filters: Filter[] } interface State { searchString: string - filters: Filter[] liveUpdating: boolean } -const DUMMY_FILTERS = [ - { - id: '0', - key: 'host', - value: 'prod1-rsavage.local', - operator: '==', - }, -] - class LogsPage extends PureComponent { private interval: NodeJS.Timer @@ -70,7 +62,6 @@ class LogsPage extends PureComponent { this.state = { searchString: '', - filters: DUMMY_FILTERS, liveUpdating: false, } } @@ -96,8 +87,8 @@ class LogsPage extends PureComponent { } public render() { - const {filters, liveUpdating} = this.state - const {searchTerm} = this.props + const {liveUpdating} = this.state + const {searchTerm, filters} = this.props const count = getDeep(this.props, 'tableData.values.length', 0) @@ -112,14 +103,16 @@ class LogsPage extends PureComponent { />
    @@ -148,6 +141,17 @@ class LogsPage extends PureComponent { } } + private handleTagSelection = (selection: {tag: string; key: string}) => { + // Do something with the tag + this.props.addFilter({ + id: uuid.v4(), + key: selection.key, + value: selection.tag, + operator: '==', + }) + this.props.executeQueriesAsync() + } + private handleInterval = () => { this.props.executeQueriesAsync() } @@ -205,8 +209,18 @@ class LogsPage extends PureComponent { this.props.setSearchTermAsync(value) } - private handleUpdateFilters = (filters: Filter[]): void => { - this.setState({filters}) + private handleFilterDelete = (id: string): void => { + this.props.removeFilter(id) + this.props.executeQueriesAsync() + } + + private handleFilterChange = ( + id: string, + operator: string, + value: string + ) => { + this.props.changeFilter(id, operator, value) + this.props.executeQueriesAsync() } private handleChooseTimerange = (timeRange: TimeRange) => { @@ -239,6 +253,7 @@ const mapStateToProps = ({ histogramData, tableData, searchTerm, + filters, }, }) => ({ sources, @@ -249,6 +264,7 @@ const mapStateToProps = ({ histogramData, tableData, searchTerm, + filters, }) const mapDispatchToProps = { @@ -259,6 +275,9 @@ const mapDispatchToProps = { executeQueriesAsync, changeZoomAsync, setSearchTermAsync, + addFilter, + removeFilter, + changeFilter, } export default connect(mapStateToProps, mapDispatchToProps)(LogsPage) diff --git a/ui/src/logs/reducers/index.ts b/ui/src/logs/reducers/index.ts index 417f4472e..6ad7dffb1 100644 --- a/ui/src/logs/reducers/index.ts +++ b/ui/src/logs/reducers/index.ts @@ -1,5 +1,12 @@ -import {ActionTypes, Action} from 'src/logs/actions' -import {LogsState} from 'src/types/localStorage' +import _ from 'lodash' +import { + ActionTypes, + Action, + RemoveFilterAction, + AddFilterAction, + ChangeFilterAction, +} from 'src/logs/actions' +import {LogsState} from 'src/types/logs' const defaultState: LogsState = { currentSource: null, @@ -11,6 +18,42 @@ const defaultState: LogsState = { tableData: [], histogramData: [], searchTerm: null, + filters: [], +} + +const removeFilter = ( + state: LogsState, + action: RemoveFilterAction +): LogsState => { + const {id} = action.payload + const filters = _.filter( + _.get(state, 'filters', []), + filter => filter.id !== id + ) + + return {...state, filters} +} + +const addFilter = (state: LogsState, action: AddFilterAction): LogsState => { + const {filter} = action.payload + + return {...state, filters: [..._.get(state, 'filters', []), filter]} +} + +const changeFilter = ( + state: LogsState, + action: ChangeFilterAction +): LogsState => { + const {id, operator, value} = action.payload + + const mappedFilters = _.map(_.get(state, 'filters', []), f => { + if (f.id === id) { + return {...f, operator, value} + } + return f + }) + + return {...state, filters: mappedFilters} } export default (state: LogsState = defaultState, action: Action) => { @@ -37,6 +80,12 @@ export default (state: LogsState = defaultState, action: Action) => { case ActionTypes.SetSearchTerm: const {searchTerm} = action.payload return {...state, searchTerm} + case ActionTypes.AddFilter: + return addFilter(state, action) + case ActionTypes.RemoveFilter: + return removeFilter(state, action) + case ActionTypes.ChangeFilter: + return changeFilter(state, action) default: return state } diff --git a/ui/src/logs/utils/index.ts b/ui/src/logs/utils/index.ts index c06d08d8f..d3fcd23a6 100644 --- a/ui/src/logs/utils/index.ts +++ b/ui/src/logs/utils/index.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import moment from 'moment' import uuid from 'uuid' +import {Filter} from 'src/types/logs' import {TimeRange, Namespace, QueryConfig} from 'src/types' import {NULL_STRING} from 'src/shared/constants/queryFillOptions' import { @@ -39,6 +40,11 @@ const tableFields = [ type: 'field', value: 'timestamp', }, + { + alias: 'message', + type: 'field', + value: 'message', + }, { alias: 'severity_text', type: 'field', @@ -64,11 +70,6 @@ const tableFields = [ type: 'field', value: 'host', }, - { - alias: 'message', - type: 'field', - value: 'message', - }, ] const defaultQueryConfig = { @@ -80,9 +81,46 @@ const defaultQueryConfig = { tags: {}, } +const keyMapping = (key: string): string => { + switch (key) { + case 'severity_1': + return 'severity' + default: + return key + } +} + +const operatorMapping = (operator: string): string => { + switch (operator) { + case '==': + return '=' + default: + return operator + } +} + +const valueMapping = (operator: string, value): string => { + if (operator === '=~') { + return `${new RegExp(value)}` + } else { + return `'${value}'` + } +} + +export const filtersClause = (filters: Filter[]): string => { + return _.map( + filters, + (filter: Filter) => + `"${keyMapping(filter.key)}" ${operatorMapping( + filter.operator + )} ${valueMapping(filter.operator, filter.value)}` + ).join(' AND ') +} + export function buildLogQuery( timeRange: TimeRange, config: QueryConfig, + filters: Filter[], searchTerm: string | null = null ): string { const {groupBy, fill = NULL_STRING, tags, areTagsAccepted} = config @@ -96,6 +134,10 @@ export function buildLogQuery( condition = `${condition} AND message =~ ${new RegExp(searchTerm)}` } + if (!_.isEmpty(filters)) { + condition = `${condition} AND ${filtersClause(filters)}` + } + return `${select}${condition}${dimensions}${fillClause}` } diff --git a/ui/src/types/localStorage.ts b/ui/src/types/localStorage.ts index 5400c455e..f466a3b76 100644 --- a/ui/src/types/localStorage.ts +++ b/ui/src/types/localStorage.ts @@ -1,16 +1,5 @@ -import {QueryConfig, TimeRange, Namespace, Source} from 'src/types' - -export interface LogsState { - currentSource: Source | null - currentNamespaces: Namespace[] - currentNamespace: Namespace | null - timeRange: TimeRange - histogramQueryConfig: QueryConfig | null - histogramData: object[] - tableQueryConfig: QueryConfig | null - tableData: object[] - searchTerm: string | null -} +import {QueryConfig, TimeRange} from 'src/types' +import {LogsState} from 'src/types/logs' export interface LocalStorage { VERSION: VERSION diff --git a/ui/src/types/logs.ts b/ui/src/types/logs.ts new file mode 100644 index 000000000..ba4c9319d --- /dev/null +++ b/ui/src/types/logs.ts @@ -0,0 +1,21 @@ +import {QueryConfig, TimeRange, Namespace, Source} from 'src/types' + +export interface Filter { + id: string + key: string + value: string + operator: string +} + +export interface LogsState { + currentSource: Source | null + currentNamespaces: Namespace[] + currentNamespace: Namespace | null + timeRange: TimeRange + histogramQueryConfig: QueryConfig | null + histogramData: object[] + tableQueryConfig: QueryConfig | null + tableData: object[] + searchTerm: string | null + filters: Filter[] +}