mirror of https://github.com/grafana/grafana
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 sizepull/17132/head^2
parent
bd5bcea5d0
commit
db48ec1f08
@ -0,0 +1,118 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { css, cx } from 'emotion'; |
||||
import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton } from '@grafana/ui'; |
||||
|
||||
import { LogsModel, LogRowModel } from 'app/core/logs_model'; |
||||
import ElapsedTime from './ElapsedTime'; |
||||
import { ButtonSize, ButtonVariant } from '@grafana/ui/src/components/Button/AbstractButton'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({ |
||||
logsRowsLive: css` |
||||
label: logs-rows-live; |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
height: 65vh; |
||||
overflow-y: auto; |
||||
:first-child { |
||||
margin-top: auto !important; |
||||
} |
||||
`,
|
||||
logsRowFresh: css` |
||||
label: logs-row-fresh; |
||||
color: ${theme.colors.text}; |
||||
background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.gray1 }, theme.type)}; |
||||
`,
|
||||
logsRowOld: css` |
||||
label: logs-row-old; |
||||
opacity: 0.8; |
||||
`,
|
||||
logsRowsIndicator: css` |
||||
font-size: ${theme.typography.size.md}; |
||||
padding: ${theme.spacing.sm} 0; |
||||
display: flex; |
||||
align-items: center; |
||||
`,
|
||||
}); |
||||
|
||||
export interface Props extends Themeable { |
||||
logsResult?: LogsModel; |
||||
stopLive: () => void; |
||||
} |
||||
|
||||
export interface State { |
||||
renderCount: number; |
||||
} |
||||
|
||||
class LiveLogs extends PureComponent<Props, State> { |
||||
private liveEndDiv: HTMLDivElement = null; |
||||
|
||||
constructor(props: Props) { |
||||
super(props); |
||||
this.state = { renderCount: 0 }; |
||||
} |
||||
|
||||
componentDidUpdate(prevProps: Props) { |
||||
const prevRows: LogRowModel[] = prevProps.logsResult ? prevProps.logsResult.rows : []; |
||||
const rows: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : []; |
||||
|
||||
if (prevRows !== rows) { |
||||
this.setState({ |
||||
renderCount: this.state.renderCount + 1, |
||||
}); |
||||
} |
||||
|
||||
if (this.liveEndDiv) { |
||||
this.liveEndDiv.scrollIntoView(false); |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
const { theme } = this.props; |
||||
const { renderCount } = this.state; |
||||
const styles = getStyles(theme); |
||||
const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : []; |
||||
|
||||
return ( |
||||
<> |
||||
<div className={cx(['logs-rows', styles.logsRowsLive])}> |
||||
{rowsToRender.map((row: any, index) => { |
||||
return ( |
||||
<div |
||||
className={row.fresh ? cx(['logs-row', styles.logsRowFresh]) : cx(['logs-row', styles.logsRowOld])} |
||||
key={`${row.timeEpochMs}-${index}`} |
||||
> |
||||
<div className="logs-row__localtime" title={`${row.timestamp} (${row.timeFromNow})`}> |
||||
{row.timeLocal} |
||||
</div> |
||||
<div className="logs-row__message">{row.entry}</div> |
||||
</div> |
||||
); |
||||
})} |
||||
<div |
||||
ref={element => { |
||||
this.liveEndDiv = element; |
||||
if (this.liveEndDiv) { |
||||
this.liveEndDiv.scrollIntoView(false); |
||||
} |
||||
}} |
||||
/> |
||||
</div> |
||||
<div className={cx([styles.logsRowsIndicator])}> |
||||
<span> |
||||
Last line received: <ElapsedTime renderCount={renderCount} humanize={true} /> ago |
||||
</span> |
||||
<LinkButton |
||||
onClick={this.props.stopLive} |
||||
size={ButtonSize.Medium} |
||||
variant={ButtonVariant.Transparent} |
||||
style={{ color: theme.colors.orange }} |
||||
> |
||||
Stop Live |
||||
</LinkButton> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export const LiveLogsWithTheme = withTheme(LiveLogs); |
@ -0,0 +1,550 @@ |
||||
import { liveOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; |
||||
import { DataSourceApi, DataQuery } from '@grafana/ui/src/types/datasource'; |
||||
|
||||
import { ExploreId, ExploreState } from 'app/types'; |
||||
import { actionCreatorFactory } from 'app/core/redux/actionCreatorFactory'; |
||||
import { |
||||
startSubscriptionsEpic, |
||||
startSubscriptionsAction, |
||||
SubscriptionDataReceivedPayload, |
||||
startSubscriptionAction, |
||||
startSubscriptionEpic, |
||||
limitMessageRatePayloadAction, |
||||
} from './epics'; |
||||
import { makeExploreItemState } from './reducers'; |
||||
import { epicTester } from 'test/core/redux/epicTester'; |
||||
import { |
||||
resetExploreAction, |
||||
updateDatasourceInstanceAction, |
||||
changeRefreshIntervalAction, |
||||
clearQueriesAction, |
||||
} from './actionTypes'; |
||||
|
||||
const setup = (options: any = {}) => { |
||||
const url = '/api/datasources/proxy/20/api/prom/tail?query=%7Bfilename%3D%22%2Fvar%2Flog%2Fdocker.log%22%7D'; |
||||
const webSocketUrl = 'ws://localhost' + url; |
||||
const refId = options.refId || 'A'; |
||||
const exploreId = ExploreId.left; |
||||
const datasourceInstance: DataSourceApi = options.datasourceInstance || { |
||||
id: 1337, |
||||
query: jest.fn(), |
||||
name: 'test', |
||||
testDatasource: jest.fn(), |
||||
convertToStreamTargets: () => [ |
||||
{ |
||||
url, |
||||
refId, |
||||
}, |
||||
], |
||||
resultToSeriesData: data => [data], |
||||
}; |
||||
const itemState = makeExploreItemState(); |
||||
const explore: Partial<ExploreState> = { |
||||
[exploreId]: { |
||||
...itemState, |
||||
datasourceInstance, |
||||
refreshInterval: options.refreshInterval || liveOption.value, |
||||
queries: [{} as DataQuery], |
||||
}, |
||||
}; |
||||
const state: any = { |
||||
explore, |
||||
}; |
||||
|
||||
return { url, state, refId, webSocketUrl, exploreId }; |
||||
}; |
||||
|
||||
const dataReceivedActionCreator = actionCreatorFactory<SubscriptionDataReceivedPayload>('test').create(); |
||||
|
||||
describe('startSubscriptionsEpic', () => { |
||||
describe('when startSubscriptionsAction is dispatched', () => { |
||||
describe('and datasource supports convertToStreamTargets', () => { |
||||
describe('and explore is Live', () => { |
||||
it('then correct actions should be dispatched', () => { |
||||
const { state, refId, webSocketUrl, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionsEpic, state) |
||||
.whenActionIsDispatched(startSubscriptionsAction({ exploreId, dataReceivedActionCreator })) |
||||
.thenResultingActionsEqual( |
||||
startSubscriptionAction({ |
||||
exploreId, |
||||
refId, |
||||
url: webSocketUrl, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and explore is not Live', () => { |
||||
it('then no actions should be dispatched', () => { |
||||
const { state, exploreId } = setup({ refreshInterval: '10s' }); |
||||
|
||||
epicTester(startSubscriptionsEpic, state) |
||||
.whenActionIsDispatched(startSubscriptionsAction({ exploreId, dataReceivedActionCreator })) |
||||
.thenNoActionsWhereDispatched(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('and datasource does not support streaming', () => { |
||||
it('then no actions should be dispatched', () => { |
||||
const { state, exploreId } = setup({ datasourceInstance: {} }); |
||||
|
||||
epicTester(startSubscriptionsEpic, state) |
||||
.whenActionIsDispatched(startSubscriptionsAction({ exploreId, dataReceivedActionCreator })) |
||||
.thenNoActionsWhereDispatched(); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('startSubscriptionEpic', () => { |
||||
describe('when startSubscriptionAction is dispatched', () => { |
||||
describe('and datasource supports resultToSeriesData', () => { |
||||
it('then correct actions should be dispatched', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ url: webSocketUrl, refId, exploreId, dataReceivedActionCreator }) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and datasource does not support resultToSeriesData', () => { |
||||
it('then no actions should be dispatched', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup({ datasourceInstance: {} }); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ url: webSocketUrl, refId, exploreId, dataReceivedActionCreator }) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenNoActionsWhereDispatched(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('when an subscription is active', () => { |
||||
describe('and resetExploreAction is dispatched', () => { |
||||
it('then subscription should be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ url: webSocketUrl, refId, exploreId, dataReceivedActionCreator }) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(resetExploreAction()) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and updateDatasourceInstanceAction is dispatched', () => { |
||||
describe('and exploreId matches the websockets', () => { |
||||
it('then subscription should be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId, datasourceInstance: null })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and exploreId does not match the websockets', () => { |
||||
it('then subscription should not be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched( |
||||
updateDatasourceInstanceAction({ exploreId: ExploreId.right, datasourceInstance: null }) |
||||
) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('and changeRefreshIntervalAction is dispatched', () => { |
||||
describe('and exploreId matches the websockets', () => { |
||||
describe('and refreshinterval is not "Live"', () => { |
||||
it('then subscription should be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId, refreshInterval: '10s' })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and refreshinterval is "Live"', () => { |
||||
it('then subscription should not be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId, refreshInterval: liveOption.value })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('and exploreId does not match the websockets', () => { |
||||
it('then subscription should not be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(changeRefreshIntervalAction({ exploreId: ExploreId.right, refreshInterval: '10s' })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('and clearQueriesAction is dispatched', () => { |
||||
describe('and exploreId matches the websockets', () => { |
||||
it('then subscription should be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(clearQueriesAction({ exploreId })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and exploreId does not match the websockets', () => { |
||||
it('then subscription should not be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched(clearQueriesAction({ exploreId: ExploreId.right })) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('and startSubscriptionAction is dispatched', () => { |
||||
describe('and exploreId and refId matches the websockets', () => { |
||||
it('then subscription should be unsubscribed', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
// This looks like we haven't stopped the subscription but we actually started the same again
|
||||
); |
||||
}); |
||||
|
||||
describe('and exploreId or refId does not match the websockets', () => { |
||||
it('then subscription should not be unsubscribed and another websocket is started', () => { |
||||
const { state, webSocketUrl, refId, exploreId } = setup(); |
||||
|
||||
epicTester(startSubscriptionEpic, state) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.thenNoActionsWhereDispatched() |
||||
.whenWebSocketReceivesData({ data: [1, 2, 3] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenActionIsDispatched( |
||||
startSubscriptionAction({ |
||||
url: webSocketUrl, |
||||
refId: 'B', |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
) |
||||
.whenWebSocketReceivesData({ data: [4, 5, 6] }) |
||||
.thenResultingActionsEqual( |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [1, 2, 3] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}), |
||||
limitMessageRatePayloadAction({ |
||||
exploreId, |
||||
data: { data: [4, 5, 6] } as any, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,159 @@ |
||||
import { Epic } from 'redux-observable'; |
||||
import { NEVER } from 'rxjs'; |
||||
import { takeUntil, mergeMap, tap, filter, map, throttleTime } from 'rxjs/operators'; |
||||
|
||||
import { StoreState, ExploreId } from 'app/types'; |
||||
import { ActionOf, ActionCreator, actionCreatorFactory } from '../../../core/redux/actionCreatorFactory'; |
||||
import { config } from '../../../core/config'; |
||||
import { |
||||
updateDatasourceInstanceAction, |
||||
resetExploreAction, |
||||
changeRefreshIntervalAction, |
||||
clearQueriesAction, |
||||
} from './actionTypes'; |
||||
import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; |
||||
import { SeriesData } from '@grafana/ui/src/types/data'; |
||||
import { EpicDependencies } from 'app/store/configureStore'; |
||||
|
||||
const convertToWebSocketUrl = (url: string) => { |
||||
const protocol = window.location.protocol === 'https' ? 'wss://' : 'ws://'; |
||||
let backend = `${protocol}${window.location.host}${config.appSubUrl}`; |
||||
if (backend.endsWith('/')) { |
||||
backend = backend.slice(0, backend.length - 1); |
||||
} |
||||
return `${backend}${url}`; |
||||
}; |
||||
|
||||
export interface StartSubscriptionsPayload { |
||||
exploreId: ExploreId; |
||||
dataReceivedActionCreator: ActionCreator<SubscriptionDataReceivedPayload>; |
||||
} |
||||
|
||||
export const startSubscriptionsAction = actionCreatorFactory<StartSubscriptionsPayload>( |
||||
'explore/START_SUBSCRIPTIONS' |
||||
).create(); |
||||
|
||||
export interface StartSubscriptionPayload { |
||||
url: string; |
||||
refId: string; |
||||
exploreId: ExploreId; |
||||
dataReceivedActionCreator: ActionCreator<SubscriptionDataReceivedPayload>; |
||||
} |
||||
|
||||
export const startSubscriptionAction = actionCreatorFactory<StartSubscriptionPayload>( |
||||
'explore/START_SUBSCRIPTION' |
||||
).create(); |
||||
|
||||
export interface SubscriptionDataReceivedPayload { |
||||
data: SeriesData; |
||||
exploreId: ExploreId; |
||||
} |
||||
|
||||
export const subscriptionDataReceivedAction = actionCreatorFactory<SubscriptionDataReceivedPayload>( |
||||
'explore/SUBSCRIPTION_DATA_RECEIVED' |
||||
).create(); |
||||
|
||||
export interface LimitMessageRatePayload { |
||||
data: SeriesData; |
||||
exploreId: ExploreId; |
||||
dataReceivedActionCreator: ActionCreator<SubscriptionDataReceivedPayload>; |
||||
} |
||||
|
||||
export const limitMessageRatePayloadAction = actionCreatorFactory<LimitMessageRatePayload>( |
||||
'explore/LIMIT_MESSAGE_RATE_PAYLOAD' |
||||
).create(); |
||||
|
||||
export const startSubscriptionsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (action$, state$) => { |
||||
return action$.ofType(startSubscriptionsAction.type).pipe( |
||||
mergeMap((action: ActionOf<StartSubscriptionsPayload>) => { |
||||
const { exploreId, dataReceivedActionCreator } = action.payload; |
||||
const { datasourceInstance, queries, refreshInterval } = state$.value.explore[exploreId]; |
||||
|
||||
if (!datasourceInstance || !datasourceInstance.convertToStreamTargets) { |
||||
return NEVER; //do nothing if datasource does not support streaming
|
||||
} |
||||
|
||||
if (!refreshInterval || !isLive(refreshInterval)) { |
||||
return NEVER; //do nothing if refresh interval is not 'LIVE'
|
||||
} |
||||
|
||||
const request: any = { targets: queries }; |
||||
return datasourceInstance.convertToStreamTargets(request).map(target => |
||||
startSubscriptionAction({ |
||||
url: convertToWebSocketUrl(target.url), |
||||
refId: target.refId, |
||||
exploreId, |
||||
dataReceivedActionCreator, |
||||
}) |
||||
); |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
export const startSubscriptionEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies> = ( |
||||
action$, |
||||
state$, |
||||
{ getWebSocket } |
||||
) => { |
||||
return action$.ofType(startSubscriptionAction.type).pipe( |
||||
mergeMap((action: ActionOf<StartSubscriptionPayload>) => { |
||||
const { url, exploreId, refId, dataReceivedActionCreator } = action.payload; |
||||
return getWebSocket(url).pipe( |
||||
takeUntil( |
||||
action$ |
||||
.ofType( |
||||
startSubscriptionAction.type, |
||||
resetExploreAction.type, |
||||
updateDatasourceInstanceAction.type, |
||||
changeRefreshIntervalAction.type, |
||||
clearQueriesAction.type |
||||
) |
||||
.pipe( |
||||
filter(action => { |
||||
if (action.type === resetExploreAction.type) { |
||||
return true; // stops all subscriptions if user navigates away
|
||||
} |
||||
|
||||
if (action.type === updateDatasourceInstanceAction.type && action.payload.exploreId === exploreId) { |
||||
return true; // stops subscriptions if user changes data source
|
||||
} |
||||
|
||||
if (action.type === changeRefreshIntervalAction.type && action.payload.exploreId === exploreId) { |
||||
return !isLive(action.payload.refreshInterval); // stops subscriptions if user changes refresh interval away from 'Live'
|
||||
} |
||||
|
||||
if (action.type === clearQueriesAction.type && action.payload.exploreId === exploreId) { |
||||
return true; // stops subscriptions if user clears all queries
|
||||
} |
||||
|
||||
return action.payload.exploreId === exploreId && action.payload.refId === refId; |
||||
}), |
||||
tap(value => console.log('Stopping subscription', value)) |
||||
) |
||||
), |
||||
mergeMap((result: any) => { |
||||
const { datasourceInstance } = state$.value.explore[exploreId]; |
||||
|
||||
if (!datasourceInstance || !datasourceInstance.resultToSeriesData) { |
||||
return [null]; //do nothing if datasource does not support streaming
|
||||
} |
||||
|
||||
return datasourceInstance |
||||
.resultToSeriesData(result, refId) |
||||
.map(data => limitMessageRatePayloadAction({ exploreId, data, dataReceivedActionCreator })); |
||||
}), |
||||
filter(action => action !== null) |
||||
); |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
export const limitMessageRateEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies> = action$ => { |
||||
return action$.ofType(limitMessageRatePayloadAction.type).pipe( |
||||
throttleTime(1), |
||||
map((action: ActionOf<LimitMessageRatePayload>) => { |
||||
const { exploreId, data, dataReceivedActionCreator } = action.payload; |
||||
return dataReceivedActionCreator({ exploreId, data }); |
||||
}) |
||||
); |
||||
}; |
@ -0,0 +1,60 @@ |
||||
import { Epic, ActionsObservable, StateObservable } from 'redux-observable'; |
||||
import { Subject } from 'rxjs'; |
||||
import { WebSocketSubject } from 'rxjs/webSocket'; |
||||
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory'; |
||||
import { StoreState } from 'app/types/store'; |
||||
import { EpicDependencies } from 'app/store/configureStore'; |
||||
|
||||
export const epicTester = ( |
||||
epic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies>, |
||||
state?: StoreState |
||||
) => { |
||||
const resultingActions: Array<ActionOf<any>> = []; |
||||
const action$ = new Subject<ActionOf<any>>(); |
||||
const state$ = new Subject<StoreState>(); |
||||
const actionObservable$ = new ActionsObservable(action$); |
||||
const stateObservable$ = new StateObservable(state$, state || ({} as StoreState)); |
||||
const websockets$: Array<Subject<any>> = []; |
||||
const dependencies: EpicDependencies = { |
||||
getWebSocket: () => { |
||||
const webSocket$ = new Subject<any>(); |
||||
websockets$.push(webSocket$); |
||||
return webSocket$ as WebSocketSubject<any>; |
||||
}, |
||||
}; |
||||
epic(actionObservable$, stateObservable$, dependencies).subscribe({ next: action => resultingActions.push(action) }); |
||||
|
||||
const whenActionIsDispatched = (action: ActionOf<any>) => { |
||||
action$.next(action); |
||||
|
||||
return instance; |
||||
}; |
||||
|
||||
const whenWebSocketReceivesData = (data: any) => { |
||||
websockets$.forEach(websocket$ => websocket$.next(data)); |
||||
|
||||
return instance; |
||||
}; |
||||
|
||||
const thenResultingActionsEqual = (...actions: Array<ActionOf<any>>) => { |
||||
expect(resultingActions).toEqual(actions); |
||||
|
||||
return instance; |
||||
}; |
||||
|
||||
const thenNoActionsWhereDispatched = () => { |
||||
expect(resultingActions).toEqual([]); |
||||
|
||||
return instance; |
||||
}; |
||||
|
||||
const instance = { |
||||
whenActionIsDispatched, |
||||
whenWebSocketReceivesData, |
||||
thenResultingActionsEqual, |
||||
thenNoActionsWhereDispatched, |
||||
}; |
||||
|
||||
return instance; |
||||
}; |
Loading…
Reference in new issue