diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 743e781c18..de0bb8ecb6 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -26,6 +26,7 @@ import {StatusPage} from 'src/status' import {HostsPage, HostPage} from 'src/hosts' import DataExplorerPage from 'src/data_explorer' import {DashboardsPage, DashboardPage} from 'src/dashboards' +import {LogsPage} from 'src/logs' import AlertsApp from 'src/alerts' import { KapacitorPage, @@ -119,6 +120,9 @@ class Root extends PureComponent<{}, State> { + + + ({ + type: ActionTypes.SetSource, + payload: { + source, + }, +}) + +export const setNamespace = (namespace: Namespace): SetNamespaceAction => ({ + type: ActionTypes.SetNamespace, + payload: { + namespace, + }, +}) + +export const setNamespaces = ( + namespaces: Namespace[] +): SetNamespacesAction => ({ + type: ActionTypes.SetNamespaces, + payload: { + namespaces, + }, +}) + +export const setTimeRange = (timeRange: TimeRange): SetTimeRangeAction => ({ + type: ActionTypes.SetTimeRange, + payload: { + timeRange, + }, +}) + +export const getSourceAsync = (sourceID: string) => async dispatch => { + const response = await getSource(sourceID) + const source = response.data + + if (source) { + const namespaces = await getDatabasesWithRetentionPolicies( + get(source, 'links.proxy', '') + ) + + if (namespaces && namespaces.length > 0) { + dispatch(setNamespace(namespaces[0])) + } + + dispatch(setNamespaces(namespaces)) + dispatch(setSource(source)) + } +} diff --git a/ui/src/logs/components/LogViewerHeader.tsx b/ui/src/logs/components/LogViewerHeader.tsx new file mode 100644 index 0000000000..33c72ef205 --- /dev/null +++ b/ui/src/logs/components/LogViewerHeader.tsx @@ -0,0 +1,103 @@ +import _ from 'lodash' +import React, {PureComponent} from 'react' +import {Source, Namespace} from 'src/types' +import Dropdown from 'src/shared/components/Dropdown' +import TimeRangeDropdown from 'src/logs/components/TimeRangeDropdown' + +import {TimeRange} from 'src/types' + +interface SourceItem { + id: string + text: string +} + +interface Props { + currentNamespace: Namespace + availableSources: Source[] + currentSource: Source | null + currentNamespaces: Namespace[] + timeRange: TimeRange + onChooseSource: (sourceID: string) => void + onChooseNamespace: (namespace: Namespace) => void + onChooseTimerange: (timeRange: TimeRange) => void +} + +class LogViewerHeader extends PureComponent { + public render(): JSX.Element { + const {timeRange} = this.props + return ( + <> + + + + + ) + } + + private handleChooseTimeRange = (timerange: TimeRange) => { + this.props.onChooseTimerange(timerange) + } + + private handleChooseSource = (item: SourceItem) => { + this.props.onChooseSource(item.id) + } + + private handleChooseNamespace = (namespace: Namespace) => { + this.props.onChooseNamespace(namespace) + } + + private get selectedSource(): string { + if (_.isEmpty(this.sourceDropDownItems)) { + return '' + } + + return this.sourceDropDownItems[0].text + } + + private get selectedNamespace(): string { + const {currentNamespace} = this.props + + if (!currentNamespace) { + return '' + } + + return `${currentNamespace.database}.${currentNamespace.retentionPolicy}` + } + + private get namespaceDropDownItems() { + const {currentNamespaces} = this.props + + return currentNamespaces.map(namespace => { + return { + text: `${namespace.database}.${namespace.retentionPolicy}`, + ...namespace, + } + }) + } + + private get sourceDropDownItems(): SourceItem[] { + const {availableSources} = this.props + + return availableSources.map(source => { + return { + text: `${source.name} @ ${source.url}`, + id: source.id, + } + }) + } +} + +export default LogViewerHeader diff --git a/ui/src/logs/components/TimeRangeDropdown.tsx b/ui/src/logs/components/TimeRangeDropdown.tsx new file mode 100644 index 0000000000..7aa1b27d87 --- /dev/null +++ b/ui/src/logs/components/TimeRangeDropdown.tsx @@ -0,0 +1,174 @@ +import React, {Component} from 'react' +import classnames from 'classnames' +import moment from 'moment' + +import FancyScrollbar from 'src/shared/components/FancyScrollbar' +import timeRanges from 'src/logs/data/timeRanges' +import {DROPDOWN_MENU_MAX_HEIGHT} from 'src/shared/constants/index' +import {ErrorHandling} from 'src/shared/decorators/errors' +import {ClickOutside} from 'src/shared/components/ClickOutside' +import CustomTimeRange from 'src/shared/components/CustomTimeRange' + +import {TimeRange} from 'src/types' + +const dateFormat = 'YYYY-MM-DD HH:mm' +const emptyTime = {lower: '', upper: ''} +const format = t => moment(t.replace(/\'/g, '')).format(dateFormat) + +interface Props { + selected: { + lower: string + upper?: string + } + + onChooseTimeRange: (timeRange: TimeRange) => void + preventCustomTimeRange?: boolean + page?: string +} + +interface State { + autobind: boolean + isOpen: boolean + isCustomTimeRangeOpen: boolean + customTimeRange: TimeRange +} + +@ErrorHandling +class TimeRangeDropdown extends Component { + public static defaultProps = { + page: 'default', + } + + constructor(props) { + super(props) + const {lower, upper} = props.selected + + const isTimeValid = moment(upper).isValid() && moment(lower).isValid() + const customTimeRange = isTimeValid ? {lower, upper} : emptyTime + + this.state = { + autobind: false, + isOpen: false, + isCustomTimeRangeOpen: false, + customTimeRange, + } + } + + public render() { + const {selected, preventCustomTimeRange, page} = this.props + const {customTimeRange, isCustomTimeRangeOpen, isOpen} = this.state + + return ( + +
+
+
+ + + {this.findTimeRangeInputValue(selected)} + + +
+
    + + {preventCustomTimeRange ? null : ( +
    +
  • Absolute Time
  • +
  • + + Date Picker + +
  • +
    + )} +
  • + {preventCustomTimeRange ? '' : 'Relative '}Time +
  • + {timeRanges.map(item => { + return ( +
  • + + {item.menuOption} + +
  • + ) + })} +
    +
+
+ {isCustomTimeRangeOpen ? ( + +
+ +
+
+ ) : null} +
+
+ ) + } + + private findTimeRangeInputValue = ({upper, lower}: TimeRange) => { + if (upper && lower) { + if (upper === 'now()') { + return `${format(lower)} - Now` + } + + return `${format(lower)} - ${format(upper)}` + } + + const selected = timeRanges.find(range => range.lower === lower) + return selected ? selected.inputValue : 'Custom' + } + + private handleClickOutside = () => { + this.setState({isOpen: false}) + } + + private handleSelection = timeRange => () => { + this.props.onChooseTimeRange(timeRange) + this.setState({customTimeRange: emptyTime, isOpen: false}) + } + + private toggleMenu = () => { + this.setState({isOpen: !this.state.isOpen}) + } + + private showCustomTimeRange = () => { + this.setState({isCustomTimeRangeOpen: true}) + } + + private handleApplyCustomTimeRange = customTimeRange => { + this.props.onChooseTimeRange({...customTimeRange}) + this.setState({customTimeRange, isOpen: false}) + } + + private handleCloseCustomTimeRange = () => { + this.setState({isCustomTimeRangeOpen: false}) + } +} +export default TimeRangeDropdown diff --git a/ui/src/logs/containers/LogsPage.tsx b/ui/src/logs/containers/LogsPage.tsx new file mode 100644 index 0000000000..0c4f041cfa --- /dev/null +++ b/ui/src/logs/containers/LogsPage.tsx @@ -0,0 +1,101 @@ +import React, {PureComponent} from 'react' +import {connect} from 'react-redux' +import {getSourceAsync, setTimeRange, setNamespace} from 'src/logs/actions' +import {getSourcesAsync} from 'src/shared/actions/sources' +import {Source, Namespace, TimeRange} from 'src/types' +import LogViewerHeader from 'src/logs/components/LogViewerHeader' + +interface Props { + sources: Source[] + currentSource: Source | null + currentNamespaces: Namespace[] + currentNamespace: Namespace + getSource: (sourceID: string) => void + getSources: () => void + setTimeRange: (timeRange: TimeRange) => void + setNamespace: (namespace: Namespace) => void + timeRange: TimeRange +} + +class LogsPage extends PureComponent { + public componentDidUpdate() { + if (!this.props.currentSource) { + this.props.getSource(this.props.sources[0].id) + } + } + + public componentDidMount() { + this.props.getSources() + } + + public render() { + return ( +
+
+
+
+

Log Viewer

+
+
{this.header}
+
+
+
+ ) + } + + private get header(): JSX.Element { + const { + sources, + currentSource, + currentNamespaces, + timeRange, + currentNamespace, + } = this.props + + return ( + + ) + } + + private handleChooseTimerange = (timeRange: TimeRange) => { + this.props.setTimeRange(timeRange) + } + + private handleChooseSource = (sourceID: string) => { + this.props.getSource(sourceID) + } + + private handleChooseNamespace = (namespace: Namespace) => { + // Do flip + this.props.setNamespace(namespace) + } +} + +const mapStateToProps = ({ + sources, + logs: {currentSource, currentNamespaces, timeRange, currentNamespace}, +}) => ({ + sources, + currentSource, + currentNamespaces, + timeRange, + currentNamespace, +}) + +const mapDispatchToProps = { + getSource: getSourceAsync, + getSources: getSourcesAsync, + setTimeRange, + setNamespace, +} + +export default connect(mapStateToProps, mapDispatchToProps)(LogsPage) diff --git a/ui/src/logs/data/timeRanges.ts b/ui/src/logs/data/timeRanges.ts new file mode 100644 index 0000000000..e17b2b7ff8 --- /dev/null +++ b/ui/src/logs/data/timeRanges.ts @@ -0,0 +1,74 @@ +export default [ + { + defaultGroupBy: '10s', + seconds: 60, + inputValue: 'Past 1m', + lower: 'now() - 1m', + upper: null, + menuOption: 'Past 1m', + }, + { + defaultGroupBy: '10s', + seconds: 300, + inputValue: 'Past 5m', + lower: 'now() - 5m', + upper: null, + menuOption: 'Past 5m', + }, + { + defaultGroupBy: '1m', + seconds: 900, + inputValue: 'Past 15m', + lower: 'now() - 15m', + upper: null, + menuOption: 'Past 15m', + }, + { + defaultGroupBy: '1m', + seconds: 1800, + inputValue: 'Past 30m', + lower: 'now() - 30m', + upper: null, + menuOption: 'Past 30m', + }, + { + defaultGroupBy: '1m', + seconds: 3600, + inputValue: 'Past 1h', + lower: 'now() - 1h', + upper: null, + menuOption: 'Past 1h', + }, + { + defaultGroupBy: '1m', + seconds: 5200, + inputValue: 'Past 2h', + lower: 'now() - 2h', + upper: null, + menuOption: 'Past 2h', + }, + { + defaultGroupBy: '1m', + seconds: 21600, + inputValue: 'Past 6h', + lower: 'now() - 6h', + upper: null, + menuOption: 'Past 6h', + }, + { + defaultGroupBy: '5m', + seconds: 43200, + inputValue: 'Past 12h', + lower: 'now() - 12h', + upper: null, + menuOption: 'Past 12h', + }, + { + defaultGroupBy: '10m', + seconds: 86400, + inputValue: 'Past 24h', + lower: 'now() - 24h', + upper: null, + menuOption: 'Past 24h', + }, +] diff --git a/ui/src/logs/index.ts b/ui/src/logs/index.ts new file mode 100644 index 0000000000..bf297d5010 --- /dev/null +++ b/ui/src/logs/index.ts @@ -0,0 +1,3 @@ +import LogsPage from 'src/logs/containers/LogsPage' + +export {LogsPage} diff --git a/ui/src/logs/reducers/index.ts b/ui/src/logs/reducers/index.ts new file mode 100644 index 0000000000..1d73ad3c9d --- /dev/null +++ b/ui/src/logs/reducers/index.ts @@ -0,0 +1,31 @@ +import {Source, Namespace, TimeRange} from 'src/types' +import {ActionTypes, Action} from 'src/logs/actions' + +interface LogsState { + currentSource: Source | null + currentNamespaces: Namespace[] + currentNamespace: Namespace | null + timeRange: TimeRange +} + +const defaultState = { + currentSource: null, + currentNamespaces: [], + timeRange: {lower: 'now() - 1m', upper: null}, + currentNamespace: null, +} + +export default (state: LogsState = defaultState, action: Action) => { + switch (action.type) { + case ActionTypes.SetSource: + return {...state, currentSource: action.payload.source} + case ActionTypes.SetNamespaces: + return {...state, currentNamespaces: action.payload.namespaces} + case ActionTypes.SetTimeRange: + return {...state, timeRange: action.payload.timeRange} + case ActionTypes.SetNamespace: + return {...state, currentNamespace: action.payload.namespace} + default: + return state + } +} diff --git a/ui/src/shared/apis/databases.ts b/ui/src/shared/apis/databases.ts new file mode 100644 index 0000000000..3bcf8dd2a6 --- /dev/null +++ b/ui/src/shared/apis/databases.ts @@ -0,0 +1,35 @@ +import _ from 'lodash' + +import {showDatabases, showRetentionPolicies} from 'src/shared/apis/metaQuery' +import showDatabasesParser from 'src/shared/parsing/showDatabases' +import showRetentionPoliciesParser from 'src/shared/parsing/showRetentionPolicies' + +import {Namespace} from 'src/types/query' + +export const getDatabasesWithRetentionPolicies = async ( + proxy: string +): Promise => { + try { + const {data} = await showDatabases(proxy) + const {databases} = showDatabasesParser(data) + const rps = await showRetentionPolicies(proxy, databases) + const namespaces = rps.data.results.reduce((acc, result, index) => { + const {retentionPolicies} = showRetentionPoliciesParser(result) + + const dbrp = retentionPolicies.map(rp => ({ + database: databases[index], + retentionPolicy: rp.name, + })) + + return [...acc, ...dbrp] + }, []) + + const sorted = _.sortBy(namespaces, ({database}: Namespace) => + database.toLowerCase() + ) + + return sorted + } catch (err) { + console.error(err) + } +} diff --git a/ui/src/shared/components/CustomTimeRange.js b/ui/src/shared/components/CustomTimeRange.js index 606d67961a..056605c0c4 100644 --- a/ui/src/shared/components/CustomTimeRange.js +++ b/ui/src/shared/components/CustomTimeRange.js @@ -17,7 +17,7 @@ class CustomTimeRange extends Component { } componentDidMount() { - const {timeRange} = this.props + const {timeRange, timeInterval} = this.props const lower = rome(this.lower, { dateValidator: rome.val.beforeEq(this.upper), @@ -26,6 +26,7 @@ class CustomTimeRange extends Component { autoClose: false, autoHideOnBlur: false, autoHideOnClick: false, + timeInterval, }) const upper = rome(this.upper, { @@ -35,6 +36,7 @@ class CustomTimeRange extends Component { initialValue: this.getInitialDate(timeRange.upper), autoHideOnBlur: false, autoHideOnClick: false, + timeInterval, }) this.lowerCal = lower @@ -239,7 +241,11 @@ class CustomTimeRange extends Component { } } -const {func, shape, string} = PropTypes +CustomTimeRange.defaultProps = { + timeInterval: 1800, +} + +const {func, shape, string, number} = PropTypes CustomTimeRange.propTypes = { onApplyTimeRange: func.isRequired, @@ -247,6 +253,7 @@ CustomTimeRange.propTypes = { lower: string.isRequired, upper: string, }).isRequired, + timeInterval: number, onClose: func, page: string, } diff --git a/ui/src/shared/components/DatabaseList.tsx b/ui/src/shared/components/DatabaseList.tsx index 7622929128..8b5d1128de 100644 --- a/ui/src/shared/components/DatabaseList.tsx +++ b/ui/src/shared/components/DatabaseList.tsx @@ -6,14 +6,12 @@ import _ from 'lodash' import {QueryConfig, Source} from 'src/types' import {Namespace} from 'src/types/query' -import {showDatabases, showRetentionPolicies} from 'src/shared/apis/metaQuery' -import showDatabasesParser from 'src/shared/parsing/showDatabases' -import showRetentionPoliciesParser from 'src/shared/parsing/showRetentionPolicies' - import DatabaseListItem from 'src/shared/components/DatabaseListItem' import FancyScrollbar from 'src/shared/components/FancyScrollbar' import {ErrorHandling} from 'src/shared/decorators/errors' +import {getDatabasesWithRetentionPolicies} from 'src/shared/apis/databases' + interface DatabaseListProps { query: QueryConfig querySource?: Source @@ -80,24 +78,7 @@ class DatabaseList extends Component { const proxy = _.get(querySource, ['links', 'proxy'], source.links.proxy) try { - const {data} = await showDatabases(proxy) - const {databases} = showDatabasesParser(data) - const rps = await showRetentionPolicies(proxy, databases) - const namespaces = rps.data.results.reduce((acc, result, index) => { - const {retentionPolicies} = showRetentionPoliciesParser(result) - - const dbrp = retentionPolicies.map(rp => ({ - database: databases[index], - retentionPolicy: rp.name, - })) - - return [...acc, ...dbrp] - }, []) - - const sorted = _.sortBy(namespaces, ({database}: Namespace) => - database.toLowerCase() - ) - + const sorted = await getDatabasesWithRetentionPolicies(proxy) this.setState({namespaces: sorted}) } catch (err) { console.error(err) diff --git a/ui/src/side_nav/containers/SideNav.tsx b/ui/src/side_nav/containers/SideNav.tsx index 5f28254bf7..084baea061 100644 --- a/ui/src/side_nav/containers/SideNav.tsx +++ b/ui/src/side_nav/containers/SideNav.tsx @@ -1,3 +1,4 @@ +import _ from 'lodash' import React, {PureComponent} from 'react' import {withRouter, Link} from 'react-router' import {connect} from 'react-redux' @@ -14,10 +15,13 @@ import { } from 'src/side_nav/components/NavItems' import {DEFAULT_HOME_PAGE} from 'src/shared/constants' -import {Params, Location, Links, Me} from 'src/types/sideNav' import {ErrorHandling} from 'src/shared/decorators/errors' +import {Params, Location, Links, Me} from 'src/types/sideNav' +import {Source} from 'src/types' + interface Props { + sources: Source[] params: Params location: Location isHidden: boolean @@ -42,9 +46,13 @@ class SideNav extends PureComponent { logoutLink, links, me, + sources = [], } = this.props - const sourcePrefix = `/sources/${sourceID}` + const defaultSource = sources.find(s => s.default) + const id = sourceID || _.get(defaultSource, 'id', 0) + + const sourcePrefix = `/sources/${id}` const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer` const isDefaultPage = location.split('/').includes(DEFAULT_HOME_PAGE) @@ -69,6 +77,16 @@ class SideNav extends PureComponent { > + + + + + { } const mapStateToProps = ({ + sources, auth: {isUsingAuth, logoutLink, me}, app: { ephemeral: {inPresentationMode}, }, links, }) => ({ + sources, isHidden: inPresentationMode, isUsingAuth, logoutLink, diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index daa00e1734..e2a299853e 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -7,6 +7,7 @@ import errorsMiddleware from 'shared/middleware/errors' import {resizeLayout} from 'shared/middleware/resizeLayout' import {queryStringConfig} from 'shared/middleware/queryStringConfig' import statusReducers from 'src/status/reducers' +import logsReducer from 'src/logs/reducers' import sharedReducers from 'shared/reducers' import dataExplorerReducers from 'src/data_explorer/reducers' import adminReducers from 'src/admin/reducers' @@ -29,6 +30,7 @@ const rootReducer = combineReducers({ cellEditorOverlay, overlayTechnology, dashTimeV1, + logs: logsReducer, routing: routerReducer, services: servicesReducer, script: scriptReducer,