From db48ec1f08bfdfc53d087d42e4bdb8e397e10dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 20 May 2019 13:28:23 +0200 Subject: [PATCH] Explore: Adds Live option for supported datasources (#17062) * Wip: Initial commit * Refactor: Adds support in Loki datasource for streaming * Refactor: Adds Live option to RefreshInterval * Refactor: Adds styles to logrows * Style: Reverses the order of Explore layout on Live * Refactor: Adds LiveLogs component * Tests: Adds tests for epics * Style: Adds animation to Live in RefreshPicker * Refactor: Adds ElapsedTime and progress line to LiveLogs * Style: Adds specific colors to each theme * Refactor: Adds support for Lokis new API * Fix: Adds null to resulting empty array * Refactor: Limits the rate of incoming messages from websockets * Refactor: Throttles messages instead for simplicity * Refactor: Optimizes row processing performance * Refactor: Adds stop live button * Fix: Fixes so that RefreshPicker shows the correct value when called programmatically * Refactor: Merges with master and removes a console.log * Refactor: Sorts rows in correct order and fixes minor UI issues * Refactor: Adds minor improvments to sorting and container size --- package.json | 1 + .../src/components/Button/AbstractButton.tsx | 6 + .../RefreshPicker/RefreshPicker.tsx | 9 +- .../RefreshPicker/_RefreshPicker.scss | 18 + .../src/components/Select/ButtonSelect.tsx | 2 +- .../components/SetInterval/SetInterval.tsx | 15 +- packages/grafana-ui/src/types/datasource.ts | 5 + .../grafana-ui/src/utils/moment_wrapper.ts | 3 + public/app/core/utils/explore.ts | 36 +- public/app/features/explore/ElapsedTime.tsx | 33 +- public/app/features/explore/Explore.tsx | 3 + .../app/features/explore/ExploreToolbar.tsx | 30 +- public/app/features/explore/LiveLogs.tsx | 118 ++++ public/app/features/explore/LogRow.tsx | 1 - public/app/features/explore/LogsContainer.tsx | 34 +- public/app/features/explore/state/actions.ts | 11 + .../app/features/explore/state/epics.test.ts | 550 ++++++++++++++++++ public/app/features/explore/state/epics.ts | 159 +++++ public/app/features/explore/state/reducers.ts | 81 ++- .../app/plugins/datasource/loki/datasource.ts | 36 ++ public/app/store/configureStore.ts | 21 +- public/app/types/explore.ts | 3 + public/sass/pages/_explore.scss | 13 + public/test/core/redux/epicTester.ts | 60 ++ yarn.lock | 5 + 25 files changed, 1226 insertions(+), 27 deletions(-) create mode 100644 public/app/features/explore/LiveLogs.tsx create mode 100644 public/app/features/explore/state/epics.test.ts create mode 100644 public/app/features/explore/state/epics.ts create mode 100644 public/test/core/redux/epicTester.ts diff --git a/package.json b/package.json index 35eb408ef02..30721ddaf6a 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "react-window": "1.7.1", "redux": "4.0.1", "redux-logger": "3.0.6", + "redux-observable": "1.1.0", "redux-thunk": "2.3.0", "remarkable": "1.7.1", "reselect": "4.0.0", diff --git a/packages/grafana-ui/src/components/Button/AbstractButton.tsx b/packages/grafana-ui/src/components/Button/AbstractButton.tsx index 38f225273ad..ee59272794e 100644 --- a/packages/grafana-ui/src/components/Button/AbstractButton.tsx +++ b/packages/grafana-ui/src/components/Button/AbstractButton.tsx @@ -75,6 +75,12 @@ const getButtonStyles = (theme: GrafanaTheme, size: ButtonSize, variant: ButtonV iconDistance = theme.spacing.xs; height = theme.height.sm; break; + case ButtonSize.Medium: + padding = `${theme.spacing.sm} ${theme.spacing.md}`; + fontSize = theme.typography.size.md; + iconDistance = theme.spacing.sm; + height = theme.height.md; + break; case ButtonSize.Large: padding = `${theme.spacing.md} ${theme.spacing.lg}`; fontSize = theme.typography.size.lg; diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 2046a5a50ce..60a8973ca09 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -5,7 +5,9 @@ import { Tooltip } from '../Tooltip/Tooltip'; import { ButtonSelect } from '../Select/ButtonSelect'; export const offOption = { label: 'Off', value: '' }; +export const liveOption = { label: 'Live', value: 'LIVE' }; export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; +export const isLive = (refreshInterval: string): boolean => refreshInterval === liveOption.value; export interface Props { intervals?: string[]; @@ -13,6 +15,7 @@ export interface Props { onIntervalChanged: (interval: string) => void; value?: string; tooltip: string; + hasLiveOption?: boolean; } export class RefreshPicker extends PureComponent { @@ -36,6 +39,9 @@ export class RefreshPicker extends PureComponent { intervalsToOptions = (intervals: string[] = defaultIntervals): Array> => { const options = intervals.map(interval => ({ label: interval, value: interval })); + if (this.props.hasLiveOption) { + options.unshift(liveOption); + } options.unshift(offOption); return options; }; @@ -57,6 +63,7 @@ export class RefreshPicker extends PureComponent { const cssClasses = classNames({ 'refresh-picker': true, 'refresh-picker--off': selectedValue.label === offOption.label, + 'refresh-picker--live': selectedValue === liveOption, }); return ( @@ -68,7 +75,7 @@ export class RefreshPicker extends PureComponent { extends PureComponent> { isSearchable={false} options={options} onChange={this.onChange} - defaultValue={value} + value={value} maxMenuHeight={maxMenuHeight} components={combinedComponents} className="gf-form-select-box-button-select" diff --git a/packages/grafana-ui/src/components/SetInterval/SetInterval.tsx b/packages/grafana-ui/src/components/SetInterval/SetInterval.tsx index b44a49f9603..cdcc1f406bb 100644 --- a/packages/grafana-ui/src/components/SetInterval/SetInterval.tsx +++ b/packages/grafana-ui/src/components/SetInterval/SetInterval.tsx @@ -1,8 +1,10 @@ import { PureComponent } from 'react'; -import { interval, Subscription, empty, Subject } from 'rxjs'; +import { interval, Subscription, Subject, of, NEVER } from 'rxjs'; import { tap, switchMap } from 'rxjs/operators'; +import _ from 'lodash'; import { stringToMs } from '../../utils/string'; +import { isLive } from '../RefreshPicker/RefreshPicker'; interface Props { func: () => any; // TODO @@ -24,7 +26,10 @@ export class SetInterval extends PureComponent { this.subscription = this.propsSubject .pipe( switchMap(props => { - return props.loading ? empty() : interval(stringToMs(props.interval)); + if (isLive(props.interval)) { + return of({}); + } + return props.loading ? NEVER : interval(stringToMs(props.interval)); }), tap(() => this.props.func()) ) @@ -32,7 +37,11 @@ export class SetInterval extends PureComponent { this.propsSubject.next(this.props); } - componentDidUpdate() { + componentDidUpdate(prevProps: Props) { + if (_.isEqual(prevProps, this.props)) { + return; + } + this.propsSubject.next(this.props); } diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 759ee05d681..36506969117 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -84,6 +84,7 @@ export interface DataSourcePluginMeta extends PluginMeta { category?: string; queryOptions?: PluginMetaQueryOptions; sort?: number; + supportsStreaming?: boolean; } interface PluginMetaQueryOptions { @@ -157,6 +158,10 @@ export abstract class DataSourceApi< */ abstract query(options: DataQueryRequest, observer?: DataStreamObserver): Promise; + convertToStreamTargets?(options: DataQueryRequest): Array<{ url: string; refId: string }>; + + resultToSeriesData?(data: any, refId: string): SeriesData[]; + /** * Test & verify datasource settings & connection details */ diff --git a/packages/grafana-ui/src/utils/moment_wrapper.ts b/packages/grafana-ui/src/utils/moment_wrapper.ts index 063c427372b..755f92a899a 100644 --- a/packages/grafana-ui/src/utils/moment_wrapper.ts +++ b/packages/grafana-ui/src/utils/moment_wrapper.ts @@ -43,6 +43,9 @@ export interface DateTimeLocale { export interface DateTimeDuration { asHours: () => number; + hours: () => number; + minutes: () => number; + seconds: () => number; } export interface DateTime extends Object { diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 7a19fd5a822..e82ba9c3409 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -33,8 +33,9 @@ import { QueryOptions, ResultGetter, } from 'app/types/explore'; -import { LogsDedupStrategy, seriesDataToLogsModel } from 'app/core/logs_model'; +import { LogsDedupStrategy, seriesDataToLogsModel, LogsModel, LogRowModel } from 'app/core/logs_model'; import { toUtc } from '@grafana/ui/src/utils/moment_wrapper'; +import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -529,3 +530,36 @@ export const getRefIds = (value: any): string[] => { return _.uniq(_.flatten(refIds)); }; + +const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { + if (a.timeEpochMs < b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs > b.timeEpochMs) { + return 1; + } + + return 0; +}; + +const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { + if (a.timeEpochMs > b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs < b.timeEpochMs) { + return 1; + } + + return 0; +}; + +export const sortLogsResult = (logsResult: LogsModel, refreshInterval: string) => { + const rows = logsResult ? logsResult.rows : []; + const live = isLive(refreshInterval); + live ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder); + const result: LogsModel = logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; + + return result; +}; diff --git a/public/app/features/explore/ElapsedTime.tsx b/public/app/features/explore/ElapsedTime.tsx index a2d941515cd..7f649c49703 100644 --- a/public/app/features/explore/ElapsedTime.tsx +++ b/public/app/features/explore/ElapsedTime.tsx @@ -1,8 +1,20 @@ import React, { PureComponent } from 'react'; +import { toDuration } from '@grafana/ui/src/utils/moment_wrapper'; const INTERVAL = 150; -export default class ElapsedTime extends PureComponent { +export interface Props { + time?: number; + renderCount?: number; + className?: string; + humanize?: boolean; +} + +export interface State { + elapsed: number; +} + +export default class ElapsedTime extends PureComponent { offset: number; timer: number; @@ -21,12 +33,17 @@ export default class ElapsedTime extends PureComponent { this.setState({ elapsed }); }; - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { if (nextProps.time) { clearInterval(this.timer); } else if (this.props.time) { this.start(); } + + if (nextProps.renderCount) { + clearInterval(this.timer); + this.start(); + } } componentDidMount() { @@ -39,8 +56,16 @@ export default class ElapsedTime extends PureComponent { render() { const { elapsed } = this.state; - const { className, time } = this.props; + const { className, time, humanize } = this.props; const value = (time || elapsed) / 1000; - return {value.toFixed(1)}s; + let displayValue = `${value.toFixed(1)}s`; + if (humanize) { + const duration = toDuration(elapsed); + const hours = duration.hours(); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + displayValue = hours ? `${hours}h ${minutes}m ${seconds}s` : minutes ? ` ${minutes}m ${seconds}s` : `${seconds}s`; + } + return {displayValue}; } } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 21e047399cd..eef4b8b21dc 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -87,6 +87,7 @@ interface ExploreProps { initialUI: ExploreUIState; queryErrors: DataQueryError[]; mode: ExploreMode; + isLive: boolean; } /** @@ -315,6 +316,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { update, queryErrors, mode, + isLive, } = item; const { datasource, queries, range: urlRange, ui } = (urlState || {}) as ExploreUrlState; @@ -340,6 +342,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { initialUI, queryErrors, mode, + isLive, }; } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index f37a2e391ce..9d6c4a1d3d9 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -39,15 +39,20 @@ const createResponsiveButton = (options: { buttonClassName?: string; iconClassName?: string; iconSide?: IconSide; + disabled?: boolean; }) => { const defaultOptions = { iconSide: IconSide.left, }; const props = { ...options, defaultOptions }; - const { title, onClick, buttonClassName, iconClassName, splitted, iconSide } = props; + const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props; return ( -