Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@teamsupercell/typings-for-css-modules-loader": "^2.4.0",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^13.3.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"@types/classnames": "^2.2.11",
"@types/d3": "^7.4.0",
Expand Down
16 changes: 8 additions & 8 deletions redisinsight/ui/src/components/main-router/MainRouter.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import React from 'react'
import reactRouterDom from 'react-router-dom'
import { cloneDeep } from 'lodash'
import { waitFor } from '@testing-library/react'
import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils'
import Router from 'uiSrc/Router'
import { localStorageService } from 'uiSrc/services'
import { Pages } from 'uiSrc/constants'
import { appContextSelector, setCurrentWorkspace } from 'uiSrc/slices/app/context'
import { AppWorkspace } from 'uiSrc/slices/interfaces'
import MainRouter from './MainRouter'
import * as activityMonitor from './activityMonitor'
import * as activityMonitor from './hooks/useActivityMonitor'

jest.mock('uiSrc/services', () => ({
...jest.requireActual('uiSrc/services'),
Expand Down Expand Up @@ -67,14 +68,13 @@ describe('MainRouter', () => {
expect(store.getActions()).toContainEqual(setCurrentWorkspace(AppWorkspace.Databases))
})

it('starts activity monitor on mount and stops on unmount', () => {
const startActivityMonitorSpy = jest.spyOn(activityMonitor, 'startActivityMonitor')
const stopActivityMonitorSpy = jest.spyOn(activityMonitor, 'stopActivityMonitor')
it('starts activity monitor on mount and stops on unmount', async () => {
const useActivityMonitorSpy = jest.spyOn(activityMonitor, 'useActivityMonitor')

const { unmount } = render(<Router><MainRouter /></Router>)
render(<Router><MainRouter /></Router>)

expect(startActivityMonitorSpy).toHaveBeenCalledTimes(1)
unmount()
expect(stopActivityMonitorSpy).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(useActivityMonitorSpy).toHaveBeenCalledTimes(1)
})
})
})
10 changes: 2 additions & 8 deletions redisinsight/ui/src/components/main-router/MainRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { AppWorkspace } from 'uiSrc/slices/interfaces'
import SuspenseLoader from 'uiSrc/components/main-router/components/SuspenseLoader'
import RedisStackRoutes from './components/RedisStackRoutes'
import DEFAULT_ROUTES from './constants/defaultRoutes'
import { startActivityMonitor, stopActivityMonitor } from './activityMonitor'
import { useActivityMonitor } from './hooks/useActivityMonitor'

const MainRouter = () => {
const { server } = useSelector(appInfoSelector)
Expand All @@ -28,6 +28,7 @@ const MainRouter = () => {
const dispatch = useDispatch()
const history = useHistory()
const { pathname } = useLocation()
useActivityMonitor()

const isRedisStack = server?.buildType === BuildType.RedisStack

Expand All @@ -38,13 +39,6 @@ const MainRouter = () => {
history.push(Pages.rdi)
}
}

// notify parent window of last activity
startActivityMonitor()

return () => {
stopActivityMonitor()
}
}, [])

useEffect(() => {
Expand Down
42 changes: 0 additions & 42 deletions redisinsight/ui/src/components/main-router/activityMonitor.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import set from 'lodash/set'
import { waitFor } from '@testing-library/react'
import { startActivityMonitor, stopActivityMonitor } from 'uiSrc/components/main-router/activityMonitor'
import { renderHook } from '@testing-library/react-hooks'
import { getConfig } from 'uiSrc/config'
import { mockWindowLocation } from 'uiSrc/utils/test-utils'
import { useActivityMonitor } from './useActivityMonitor'

const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
Expand All @@ -21,67 +22,136 @@ const mockWindowOpener = (postMessage = jest.fn()) => {
}
}

jest.useFakeTimers()

const browserUrl = 'http://localhost/123/browser'
const logoutUrl = 'http://localhost/#/logout'

let setHrefMock: typeof jest.fn
beforeEach(() => {
jest.resetAllMocks()

const mockDate = new Date('2024-11-22T12:00:00Z')
jest.setSystemTime(mockDate)

mockConfig()
setHrefMock = mockWindowLocation(browserUrl)
mockWindowOpener()
})

describe('Activity monitor', () => {
it('should start and stop activity monitor if window.opener and monitor origin are defined', () => {
startActivityMonitor()
describe('useActivityMonitor', () => {
it('should register event handlers on mount and unregister on unmount', () => {
const { unmount } = renderHook(useActivityMonitor)

// Verify mount behavior
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), addEventListenerProps)

stopActivityMonitor()
// Trigger unmount
unmount()

// Verify unmount behavior
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), removeEventListenerProps)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), removeEventListenerProps)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), removeEventListenerProps)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), removeEventListenerProps)
})

it('should not start or stop activity monitor if window.opener is undefined', () => {
it('should register event handlers even if window.opener is undefined', () => {
global.window.opener = undefined

startActivityMonitor()
stopActivityMonitor()
const { unmount } = renderHook(useActivityMonitor)

expect(addEventListenerSpy).not.toHaveBeenCalled()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
// Verify mount behavior
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)

// Trigger unmount
unmount()

// Verify unmount behavior
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
})

it('should not start or stop activity monitor if monitor origin is falsey', () => {
it('should not register handlers if activityMonitorOrigin is not defined', () => {
mockConfig('')

startActivityMonitor()
stopActivityMonitor()
const { unmount } = renderHook(useActivityMonitor)

// Verify mount behavior
expect(addEventListenerSpy).not.toHaveBeenCalled()

// Trigger unmount
unmount()

// Verify unmount behavior
expect(removeEventListenerSpy).not.toHaveBeenCalled()
})

it('should logout user after expected amount of inactivity', async () => {
renderHook(useActivityMonitor)
jest.advanceTimersByTime(1900 * 1000)
expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
})

it('should not logout user if hook unmounts', async () => {
const { unmount } = renderHook(useActivityMonitor)
jest.advanceTimersByTime(1700 * 1000)
expect(setHrefMock).not.toHaveBeenCalled()

unmount()

jest.advanceTimersByTime(1000 * 1000)
expect(setHrefMock).not.toHaveBeenCalled()
})

it('should keep user logged in if they stay active', async () => {
renderHook(useActivityMonitor)

const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function

// act
jest.advanceTimersByTime(1700 * 1000)
activityHandler()
jest.advanceTimersByTime(1700 * 1000)

// assert
expect(setHrefMock).not.toHaveBeenCalled()

// act
activityHandler()
jest.advanceTimersByTime(1700 * 1000)

// assert
expect(setHrefMock).not.toHaveBeenCalled()

// act
jest.advanceTimersByTime(1000 * 1000)

// assert
expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
})

it('should throttle events and call window.opener.postMessage', async () => {
const mockPostMessage = jest.fn()

mockWindowOpener(mockPostMessage)
startActivityMonitor()
renderHook(useActivityMonitor)

expect(addEventListenerSpy).toHaveBeenCalledTimes(4)

// simulate events
// act
const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function
activityHandler()
activityHandler()
activityHandler()
activityHandler()

await waitFor(() => {
expect(mockPostMessage).toHaveBeenCalledTimes(1)
})
jest.advanceTimersByTime(20_000)

// assert
expect(mockPostMessage).toHaveBeenCalledTimes(1)
})

it('should ignore errors from activity handler function', async () => {
Expand All @@ -90,7 +160,7 @@ describe('Activity monitor', () => {
})

mockWindowOpener(mockPostMessage)
startActivityMonitor()
renderHook(useActivityMonitor)

expect(addEventListenerSpy).toHaveBeenCalledTimes(4)

Expand All @@ -106,7 +176,7 @@ describe('Activity monitor', () => {

mockWindowOpener()

expect(startActivityMonitor).not.toThrow()
expect(() => renderHook(useActivityMonitor)).not.toThrow()
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import throttle from 'lodash/throttle'
import { useEffect } from 'react'
import { getConfig } from 'uiSrc/config'

const riConfig = getConfig()

const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout
const windowEvents = ['click', 'keydown', 'scroll', 'touchstart']

const SESSION_TIME_SECONDS = riConfig.app.sessionTtlSeconds
const SESSION_TIME_MS = SESSION_TIME_SECONDS * 1000
const CHECK_SESSION_INTERVAL_MS = 10000

let lastActivityTime: number
let checkInterval: ReturnType<typeof setTimeout> | null = null

const getIsMonitorEnabled = () => !!riConfig.app.activityMonitorOrigin

const onActivity = throttle(() => {
lastActivityTime = +new Date()

try {
// post event to parent window
window.opener?.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin)
} catch {
// ignore errors
}
}, throttleTimeout)

export const startActivityMonitor = () => {
lastActivityTime = +new Date()
try {
if (getIsMonitorEnabled()) {
checkInterval = setInterval(() => {
const now = +new Date()
if (now - lastActivityTime >= SESSION_TIME_MS) {
// expire session
window.location.href = `${riConfig.app.activityMonitorOrigin}/#/logout`
}
}, CHECK_SESSION_INTERVAL_MS)

windowEvents.forEach((event) => {
window.addEventListener(event, onActivity, { passive: true, capture: true })
})
}
} catch {
// ignore errors
}
}

export const stopActivityMonitor = () => {
try {
if (getIsMonitorEnabled()) {
if (checkInterval) {
clearInterval(checkInterval)
checkInterval = null
}

windowEvents.forEach((event) => {
window.removeEventListener(event, onActivity, { capture: true })
})
}
} catch {
// ignore errors
}
}

export const useActivityMonitor = () => {
useEffect(() => {
startActivityMonitor()

return () => {
stopActivityMonitor()
}
}, [])
}

export default useActivityMonitor
1 change: 1 addition & 0 deletions redisinsight/ui/src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const defaultConfig = {
returnUrlTooltip: process.env.RI_RETURN_URL_TOOLTIP || 'Back',
activityMonitorOrigin: process.env.RI_ACTIVITY_MONITOR_ORIGIN,
activityMonitorThrottleTimeout: intEnv('RI_ACTIVITY_MONITOR_THROTTLE_TIMEOUT', 30_000),
sessionTtlSeconds: intEnv('RI_SESSION_TTL_SECONDS', 30 * 60),
localResourcesBaseUrl: process.env.RI_LOCAL_RESOURCES_BASE_URL,
useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false)
},
Expand Down
Loading