diff --git a/jest.config.js b/jest.config.js index 4ebfb1bba9..2756fd4cf7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,7 @@ module.exports = { 'rehype-stringify': '/redisinsight/__mocks__/rehypeStringify.js', 'unist-util-visit': '/redisinsight/__mocks__/unistUtilsVisit.js', 'react-children-utilities': '/redisinsight/__mocks__/react-children-utilities.js', + d3: '/node_modules/d3/dist/d3.min.js', }, setupFiles: [ '/redisinsight/ui/src/setup-env.ts', @@ -38,6 +39,11 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!(monaco-editor|react-monaco-editor)/)', ], + // TODO: add tests for plugins + modulePathIgnorePatterns: [ + '/redisinsight/ui/src/packages', + '/redisinsight/ui/src/mocks', + ], coverageThreshold: { global: { statements: 70, diff --git a/package.json b/package.json index 8c9ee379cb..743eed262a 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@testing-library/user-event": "^14.4.3", "@types/axios": "^0.14.0", "@types/classnames": "^2.2.11", + "@types/d3": "^7.4.0", "@types/date-fns": "^2.6.0", "@types/detect-port": "^1.3.0", "@types/electron-store": "^3.2.0", @@ -216,6 +217,7 @@ "buffer": "^6.0.3", "classnames": "^2.3.1", "connection-string": "^4.3.2", + "d3": "^7.6.1", "date-fns": "^2.16.1", "detect-port": "^1.3.0", "electron-context-menu": "^3.1.0", diff --git a/redisinsight/ui/src/assets/img/overview/input.svg b/redisinsight/ui/src/assets/img/overview/input.svg new file mode 100644 index 0000000000..d6669fc2c0 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/input.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/key.svg b/redisinsight/ui/src/assets/img/overview/key.svg new file mode 100644 index 0000000000..e844cbdbdf --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/key.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/measure.svg b/redisinsight/ui/src/assets/img/overview/measure.svg new file mode 100644 index 0000000000..bb364a8e26 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/measure.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/memory.svg b/redisinsight/ui/src/assets/img/overview/memory.svg new file mode 100644 index 0000000000..eef854c42d --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/memory.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/output.svg b/redisinsight/ui/src/assets/img/overview/output.svg new file mode 100644 index 0000000000..6af5a48cc1 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/output.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/time.svg b/redisinsight/ui/src/assets/img/overview/time.svg new file mode 100644 index 0000000000..13cf5d166b --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/time.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/user.svg b/redisinsight/ui/src/assets/img/overview/user.svg new file mode 100644 index 0000000000..e91ef77a16 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/user.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/components/action-bar/styles.module.scss b/redisinsight/ui/src/components/action-bar/styles.module.scss index f4875b7271..c85a41dd84 100644 --- a/redisinsight/ui/src/components/action-bar/styles.module.scss +++ b/redisinsight/ui/src/components/action-bar/styles.module.scss @@ -2,14 +2,6 @@ .euiPopoverTitle { text-transform: none !important; } - - .euiButton { - min-width: 93px !important; - - &:focus { - text-decoration: none !important; - } - } } .container { diff --git a/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.spec.tsx b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.spec.tsx new file mode 100644 index 0000000000..1ed5e9379d --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import reactRouterDom from 'react-router-dom' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' +import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import AnalyticsTabs from './AnalyticsTabs' + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn, + }), +})) + +describe('StreamTabs', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('click on clusterDetails tab should call History push with /cluster-details path ', async () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + + render() + + await act(() => { + fireEvent.click(screen.getByTestId(`analytics-tab-${AnalyticsViewTab.ClusterDetails}`)) + }) + + expect(pushMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith('/instanceId/analytics/cluster-details') + }) + it('click on SlowLog tab should call History push with /slowlog path ', async () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + + render() + + await act(() => { + fireEvent.click(screen.getByTestId(`analytics-tab-${AnalyticsViewTab.SlowLog}`)) + }) + + expect(pushMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith('/instanceId/analytics/slowlog') + }) +}) diff --git a/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx new file mode 100644 index 0000000000..3e87a6de1f --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react' +import { EuiTab, EuiTabs } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' +import { useParams, useHistory } from 'react-router-dom' + +import { Pages } from 'uiSrc/constants' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' +import { analyticsSettingsSelector, setAnalyticsViewTab } from 'uiSrc/slices/analytics/settings' + +import { analyticsViewTabs } from './constants' + +const AnalyticsTabs = () => { + const { viewTab } = useSelector(analyticsSettingsSelector) + const history = useHistory() + + const { instanceId } = useParams<{ instanceId: string }>() + + const dispatch = useDispatch() + + const onSelectedTabChanged = (id: AnalyticsViewTab) => { + if (id === AnalyticsViewTab.ClusterDetails) { + history.push(Pages.clusterDetails(instanceId)) + } + if (id === AnalyticsViewTab.SlowLog) { + history.push(Pages.slowLog(instanceId)) + } + dispatch(setAnalyticsViewTab(id)) + } + + const renderTabs = useCallback(() => + [...analyticsViewTabs].map(({ id, label }) => ( + onSelectedTabChanged(id)} + key={id} + data-testid={`analytics-tab-${id}`} + > + {label} + + )), [viewTab]) + + return ( + <> + {renderTabs()} + + ) +} + +export default AnalyticsTabs diff --git a/redisinsight/ui/src/components/analytics-tabs/constants.ts b/redisinsight/ui/src/components/analytics-tabs/constants.ts new file mode 100644 index 0000000000..bb03fbb79a --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/constants.ts @@ -0,0 +1,17 @@ +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' + +interface AnalyticsTabs { + id: AnalyticsViewTab, + label: string, +} + +export const analyticsViewTabs: AnalyticsTabs[] = [ + { + id: AnalyticsViewTab.ClusterDetails, + label: 'Overview', + }, + { + id: AnalyticsViewTab.SlowLog, + label: 'Slow Log', + }, +] diff --git a/redisinsight/ui/src/components/analytics-tabs/index.ts b/redisinsight/ui/src/components/analytics-tabs/index.ts new file mode 100644 index 0000000000..8f9bcc33c3 --- /dev/null +++ b/redisinsight/ui/src/components/analytics-tabs/index.ts @@ -0,0 +1,3 @@ +import AnalyticsTabs from './AnalyticsTabs' + +export default AnalyticsTabs diff --git a/redisinsight/ui/src/components/charts/donut-chart/DonutChart.spec.tsx b/redisinsight/ui/src/components/charts/donut-chart/DonutChart.spec.tsx new file mode 100644 index 0000000000..a574e157ef --- /dev/null +++ b/redisinsight/ui/src/components/charts/donut-chart/DonutChart.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import DonutChart, { ChartData } from './DonutChart' + +const mockData: ChartData[] = [ + { value: 1, name: 'A', color: [0, 0, 0] }, + { value: 5, name: 'B', color: [10, 10, 10] }, + { value: 10, name: 'C', color: [20, 20, 20] }, + { value: 2, name: 'D', color: [30, 30, 30] }, + { value: 30, name: 'E', color: [40, 40, 40] }, + { value: 15, name: 'F', color: [50, 50, 50] }, +] + +describe('DonutChart', () => { + it('should render with empty data', () => { + expect(render()).toBeTruthy() + }) + + it('should render with data', () => { + expect(render()).toBeTruthy() + }) + + it('should not render donut with empty data', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('should not render donut with 0 values', () => { + const mockData: ChartData[] = [ + { value: 0, name: 'A', color: [0, 0, 0] }, + { value: 0, name: 'B', color: [10, 10, 10] }, + ] + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('should render svg', () => { + render() + expect(screen.getByTestId('donut-test')).toBeInTheDocument() + }) + + it('should render arcs and labels', () => { + render() + mockData.forEach(({ value, name }) => { + expect(screen.getByTestId(`arc-${name}-${value}`)).toBeInTheDocument() + expect(screen.getByTestId(`label-${name}-${value}`)).toBeInTheDocument() + }) + }) + + it('should not render arcs and labels with 0 value', () => { + const mockData: ChartData[] = [ + { value: 0, name: 'A', color: [0, 0, 0] }, + { value: 10, name: 'B', color: [10, 10, 10] }, + ] + render() + expect(screen.queryByTestId('arc-A-0')).not.toBeInTheDocument() + expect(screen.queryByTestId('label-A-0')).not.toBeInTheDocument() + }) + + it('should do not render label value if value less than 5%', () => { + render() + expect(screen.getByTestId('label-A-1')).toHaveTextContent('') + }) + + it('should render label value if value more than 5%', () => { + render() + expect(screen.getByTestId('label-E-30')).toHaveTextContent('E: 30') + }) + + it('should call render tooltip and label methods', () => { + const renderLabel = jest.fn() + const renderTooltip = jest.fn() + render() + expect(renderLabel).toBeCalled() + + fireEvent.mouseEnter(screen.getByTestId('arc-A-1')) + expect(renderTooltip).toBeCalled() + }) + + it('should set tooltip as visible on hover and hidden on leave', () => { + render() + + fireEvent.mouseEnter(screen.getByTestId('arc-A-1')) + expect(screen.getByTestId('chart-value-tooltip')).toBeVisible() + + fireEvent.mouseLeave(screen.getByTestId('arc-A-1')) + expect(screen.getByTestId('chart-value-tooltip')).not.toBeVisible() + }) +}) diff --git a/redisinsight/ui/src/components/charts/donut-chart/DonutChart.tsx b/redisinsight/ui/src/components/charts/donut-chart/DonutChart.tsx new file mode 100644 index 0000000000..aa4596f3be --- /dev/null +++ b/redisinsight/ui/src/components/charts/donut-chart/DonutChart.tsx @@ -0,0 +1,176 @@ +import cx from 'classnames' +import * as d3 from 'd3' +import { sumBy } from 'lodash' +import React, { useEffect, useRef } from 'react' +import { truncateNumberToRange } from 'uiSrc/utils' +import { rgb, RGBColor } from 'uiSrc/utils/colors' + +import styles from './styles.module.scss' + +export interface ChartData { + value: number + name: string + color: RGBColor +} + +interface IProps { + name?: string + data: ChartData[] + width?: number + height?: number + title?: React.ReactElement | string + config?: { + percentToShowLabel?: number + arcWidth?: number + margin?: number + radius?: number + } + classNames?: { + chart?: string + arc?: string + arcLabel?: string + arcLabelValue?: string + tooltip?: string + } + renderLabel?: (value: number) => string + renderTooltip?: (value: number) => string +} + +const ANIMATION_DURATION_MS = 100 + +const DonutChart = (props: IProps) => { + const { + name = '', + data, + width = 380, + height = 300, + title, + config, + classNames, + renderLabel, + renderTooltip = (v) => v, + } = props + + const margin = config?.margin || 98 + const radius = config?.radius || (width / 2 - margin) + const arcWidth = config?.arcWidth || 8 + const percentToShowLabel = config?.percentToShowLabel || 5 + + const svgRef = useRef(null) + const tooltipRef = useRef(null) + + const arc = d3.arc>() + .outerRadius(radius) + .innerRadius(radius - arcWidth) + + const arcHover = d3.arc>() + .outerRadius(radius + 4) + .innerRadius(radius - arcWidth) + + const onMouseEnterSlice = (e: MouseEvent, d: d3.PieArcDatum) => { + d3 + .select>(e.target as SVGPathElement) + .transition() + .duration(ANIMATION_DURATION_MS) + .attr('d', arcHover) + + if (tooltipRef.current) { + tooltipRef.current.innerHTML = `${d.data.name}: ${renderTooltip(d.value)}` + tooltipRef.current.style.visibility = 'visible' + tooltipRef.current.style.top = `${e.pageY + 15}px` + tooltipRef.current.style.left = `${e.pageX + 15}px` + } + } + + const onMouseLeaveSlice = (e: MouseEvent) => { + d3 + .select>(e.target as SVGPathElement) + .transition() + .duration(ANIMATION_DURATION_MS) + .attr('d', arc) + + if (tooltipRef.current) { + tooltipRef.current.style.visibility = 'hidden' + } + } + + const isShowLabel = (d: d3.PieArcDatum) => + d.endAngle - d.startAngle > (Math.PI * 2) / (100 / percentToShowLabel) + + const getLabelPosition = (d: d3.PieArcDatum) => { + const [x, y] = arc.centroid(d) + const h = Math.sqrt(x * x + y * y) + return `translate(${(x / h) * (radius + 16)}, ${((y + 4) / h) * (radius + 16)})` + } + + useEffect(() => { + const pie = d3.pie().value((d: ChartData) => d.value).sort(null) + const dataReady = pie(data.filter((d) => d.value !== 0)) + + d3 + .select(svgRef.current) + .select('g') + .remove() + + const svg = d3 + .select(svgRef.current) + .attr('width', width) + .attr('height', height) + .attr('data-testid', `donut-${name}`) + .attr('class', cx(classNames?.chart)) + .append('g') + .attr('transform', `translate(${width / 2},${height / 2})`) + + // add arcs + svg + .selectAll() + .data(dataReady) + .enter() + .append('path') + .attr('data-testid', (d) => `arc-${d.data.name}-${d.data.value}`) + .attr('d', arc) + .attr('fill', (d) => rgb(d.data.color)) + .attr('class', cx(styles.arc, classNames?.arc)) + .on('mouseenter mousemove', onMouseEnterSlice) + .on('mouseleave', onMouseLeaveSlice) + + // add labels + svg + .selectAll() + .data(dataReady) + .enter() + .append('text') + .attr('class', cx(styles.chartLabel, classNames?.arcLabel)) + .attr('transform', getLabelPosition) + .text((d) => (isShowLabel(d) ? d.data.name : '')) + .attr('data-testid', (d) => `label-${d.data.name}-${d.data.value}`) + .style('text-anchor', (d) => ((d.endAngle + d.startAngle) / 2 > Math.PI ? 'end' : 'start')) + .on('mouseenter mousemove', onMouseEnterSlice) + .on('mouseleave', onMouseLeaveSlice) + .append('tspan') + .text((d) => (isShowLabel(d) ? `: ${renderLabel ? renderLabel(d.value) : truncateNumberToRange(d.value)}` : '')) + .attr('class', cx(styles.chartLabelValue, classNames?.arcLabelValue)) + }, [data]) + + if (!data.length || sumBy(data, 'value') === 0) { + return null + } + + return ( +
+ +
+ {title && ( +
+ {title} +
+ )} +
+ ) +} + +export default DonutChart diff --git a/redisinsight/ui/src/components/charts/donut-chart/index.ts b/redisinsight/ui/src/components/charts/donut-chart/index.ts new file mode 100644 index 0000000000..bdfb67bbc5 --- /dev/null +++ b/redisinsight/ui/src/components/charts/donut-chart/index.ts @@ -0,0 +1,3 @@ +import DonutChart from './DonutChart' + +export default DonutChart diff --git a/redisinsight/ui/src/components/charts/donut-chart/styles.module.scss b/redisinsight/ui/src/components/charts/donut-chart/styles.module.scss new file mode 100644 index 0000000000..2fda31fb86 --- /dev/null +++ b/redisinsight/ui/src/components/charts/donut-chart/styles.module.scss @@ -0,0 +1,36 @@ +.wrapper { + position: relative; +} + +.innerTextContainer { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.tooltip { + position: fixed; + background: var(--separatorColor); + color: var(--htmlColor); + padding: 10px; + visibility: hidden; + border-radius: 4px; + z-index: 5; +} + +.chartLabel { + fill: var(--euiTextSubduedColor); + font-size: 12px; + font-weight: bold; + + .chartLabelValue { + font-weight: normal; + } +} + +.arc { + stroke: var(--euiColorLightestShade); + stroke-width: 2px; + cursor: pointer; +} diff --git a/redisinsight/ui/src/components/charts/index.ts b/redisinsight/ui/src/components/charts/index.ts new file mode 100644 index 0000000000..0dc80344b0 --- /dev/null +++ b/redisinsight/ui/src/components/charts/index.ts @@ -0,0 +1,5 @@ +import DonutChart from './donut-chart' + +export { + DonutChart +} diff --git a/redisinsight/ui/src/components/database-overview/components/icons.ts b/redisinsight/ui/src/components/database-overview/components/icons.ts index 2f9ca14513..f37fd56eb0 100644 --- a/redisinsight/ui/src/components/database-overview/components/icons.ts +++ b/redisinsight/ui/src/components/database-overview/components/icons.ts @@ -1,24 +1,37 @@ import KeyDarkIcon from 'uiSrc/assets/img/overview/key_dark.svg' import KeyTipIcon from 'uiSrc/assets/img/overview/key_tip.svg' import KeyLightIcon from 'uiSrc/assets/img/overview/key_light.svg' +import { ReactComponent as KeyIconSvg } from 'uiSrc/assets/img/overview/key.svg' + import MemoryDarkIcon from 'uiSrc/assets/img/overview/memory_dark.svg' import MemoryLightIcon from 'uiSrc/assets/img/overview/memory_light.svg' import MemoryTipIcon from 'uiSrc/assets/img/overview/memory_tip.svg' +import { ReactComponent as MemoryIconSvg } from 'uiSrc/assets/img/overview/memory.svg' + import MeasureLightIcon from 'uiSrc/assets/img/overview/measure_light.svg' import MeasureDarkIcon from 'uiSrc/assets/img/overview/measure_dark.svg' import MeasureTipIcon from 'uiSrc/assets/img/overview/measure_tip.svg' +import { ReactComponent as MeasureIconSvg } from 'uiSrc/assets/img/overview/measure.svg' + import TimeLightIcon from 'uiSrc/assets/img/overview/time_light.svg' import TimeDarkIcon from 'uiSrc/assets/img/overview/time_dark.svg' import TimeTipIcon from 'uiSrc/assets/img/overview/time_tip.svg' +import { ReactComponent as TimeIconSvg } from 'uiSrc/assets/img/overview/time.svg' + import UserDarkIcon from 'uiSrc/assets/img/overview/user_dark.svg' import UserLightIcon from 'uiSrc/assets/img/overview/user_light.svg' import UserTipIcon from 'uiSrc/assets/img/overview/user_tip.svg' +import { ReactComponent as UserIconSvg } from 'uiSrc/assets/img/overview/user.svg' + import InputTipIcon from 'uiSrc/assets/img/overview/input_tip.svg' import InputLightIcon from 'uiSrc/assets/img/overview/input_light.svg' import InputDarkIcon from 'uiSrc/assets/img/overview/input_dark.svg' +import { ReactComponent as InputIconSvg } from 'uiSrc/assets/img/overview/input.svg' + import OutputTipIcon from 'uiSrc/assets/img/overview/output_tip.svg' import OutputLightIcon from 'uiSrc/assets/img/overview/output_light.svg' import OutputDarkIcon from 'uiSrc/assets/img/overview/output_dark.svg' +import { ReactComponent as OutputIconSvg } from 'uiSrc/assets/img/overview/output.svg' export { KeyDarkIcon, @@ -42,4 +55,11 @@ export { OutputTipIcon, OutputLightIcon, OutputDarkIcon, + KeyIconSvg, + MemoryIconSvg, + MeasureIconSvg, + TimeIconSvg, + UserIconSvg, + InputIconSvg, + OutputIconSvg, } diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index 2bb821c78e..dd4420bff9 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -10,8 +10,9 @@ import { RedisClusterDatabasesPage, } from 'uiSrc/pages' import WorkbenchPage from 'uiSrc/pages/workbench' -import SlowLogPage from 'uiSrc/pages/slowLog' import PubSubPage from 'uiSrc/pages/pubSub' +import AnalyticsPage from 'uiSrc/pages/analytics' +import { ANALYTICS_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' @@ -26,16 +27,16 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.workbench(':instanceId'), component: WorkbenchPage, }, - { - pageName: PageNames.slowLog, - path: Pages.slowLog(':instanceId'), - component: SlowLogPage, - }, { pageName: PageNames.pubSub, path: Pages.pubSub(':instanceId'), component: PubSubPage, }, + { + path: Pages.analytics(':instanceId'), + component: AnalyticsPage, + routes: ANALYTICS_ROUTES, + }, ] const ROUTES: IRoute[] = [ diff --git a/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts b/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts index d876d5529a..6897dd80b2 100644 --- a/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts @@ -6,8 +6,25 @@ import WorkbenchPage from 'uiSrc/pages/workbench' import SlowLogPage from 'uiSrc/pages/slowLog' import PubSubPage from 'uiSrc/pages/pubSub' import EditConnection from 'uiSrc/pages/redisStack/components/edit-connection' +import ClusterDetailsPage from 'uiSrc/pages/clusterDetails' +import AnalyticsPage from 'uiSrc/pages/analytics' import COMMON_ROUTES from './commonRoutes' +const ANALYTICS_ROUTES: IRoute[] = [ + { + pageName: PageNames.slowLog, + protected: true, + path: Pages.slowLog(':instanceId'), + component: SlowLogPage, + }, + { + pageName: PageNames.clusterDetails, + protected: true, + path: Pages.clusterDetails(':instanceId'), + component: ClusterDetailsPage, + }, +] + const INSTANCE_ROUTES: IRoute[] = [ { pageName: PageNames.browser, @@ -21,18 +38,18 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.workbench(':instanceId'), component: WorkbenchPage, }, - { - pageName: PageNames.slowLog, - protected: true, - path: Pages.slowLog(':instanceId'), - component: SlowLogPage, - }, { pageName: PageNames.pubSub, protected: true, path: Pages.pubSub(':instanceId'), component: PubSubPage, }, + { + path: Pages.analytics(':instanceId'), + protected: true, + component: AnalyticsPage, + routes: ANALYTICS_ROUTES, + }, ] const ROUTES: IRoute[] = [ diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/analyticsRoutes.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/analyticsRoutes.ts new file mode 100644 index 0000000000..6f214d6fa6 --- /dev/null +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/analyticsRoutes.ts @@ -0,0 +1,16 @@ +import { IRoute, PageNames, Pages } from 'uiSrc/constants' +import ClusterDetailsPage from 'uiSrc/pages/clusterDetails' +import SlowLogPage from 'uiSrc/pages/slowLog' + +export const ANALYTICS_ROUTES: IRoute[] = [ + { + pageName: PageNames.slowLog, + path: Pages.slowLog(':instanceId'), + component: SlowLogPage, + }, + { + pageName: PageNames.clusterDetails, + path: Pages.clusterDetails(':instanceId'), + component: ClusterDetailsPage, + }, +] diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/index.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/index.ts new file mode 100644 index 0000000000..7cfe9e8d2b --- /dev/null +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/index.ts @@ -0,0 +1,5 @@ +import { ANALYTICS_ROUTES } from './analyticsRoutes' + +export { + ANALYTICS_ROUTES +} diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index 734f6674d2..9a05e6824f 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -17,6 +17,7 @@ import { EuiTitle, EuiToolTip } from '@elastic/eui' +import { ANALYTICS_ROUTES } from 'uiSrc/components/main-router/constants/sub-routes' import { PageNames, Pages } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' @@ -41,15 +42,15 @@ import PubSubSVG from 'uiSrc/assets/img/sidebar/pubsub.svg' import PubSubActiveSVG from 'uiSrc/assets/img/sidebar/pubsub_active.svg' import GithubSVG from 'uiSrc/assets/img/sidebar/github.svg' import Divider from 'uiSrc/components/divider/Divider' - import { BuildType } from 'uiSrc/constants/env' +import { ConnectionType } from 'uiSrc/slices/interfaces' + import NotificationMenu from './components/notifications-center' import styles from './styles.module.scss' const workbenchPath = `/${PageNames.workbench}` const browserPath = `/${PageNames.browser}` -const slowLogPath = `/${PageNames.slowLog}` const pubSubPath = `/${PageNames.pubSub}` interface INavigations { @@ -71,7 +72,7 @@ const NavigationMenu = () => { const [activePage, setActivePage] = useState(Pages.home) const [isHelpMenuActive, setIsHelpMenuActive] = useState(false) - const { id: connectedInstanceId = '' } = useSelector(connectedInstanceSelector) + const { id: connectedInstanceId = '', connectionType } = useSelector(connectedInstanceSelector) const { isReleaseNotesViewed } = useSelector(appElectronInfoSelector) const { server } = useSelector(appInfoSelector) @@ -86,6 +87,10 @@ const NavigationMenu = () => { dispatch(setShortcutsFlyoutState(true)) } + const isAnalyticsPath = (activePage: string) => !!ANALYTICS_ROUTES.find( + ({ path }) => (`/${last(path.split('/'))}` === activePage) + ) + const privateRoutes: INavigations[] = [ { tooltipText: 'Browser', @@ -116,12 +121,14 @@ const NavigationMenu = () => { }, }, { - tooltipText: 'Slow Log', - ariaLabel: 'SlowLog page button', - onClick: () => handleGoPage(Pages.slowLog(connectedInstanceId)), - dataTestId: 'slowlog-page-btn', + tooltipText: 'Analysis Tools', + ariaLabel: 'Analysis Tools', + onClick: () => handleGoPage(connectionType === ConnectionType.Cluster + ? Pages.clusterDetails(connectedInstanceId) + : Pages.slowLog(connectedInstanceId)), + dataTestId: 'analytics-page-btn', connectedInstanceId, - isActivePage: activePage === slowLogPath, + isActivePage: isAnalyticsPath(activePage), getClassName() { return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) }, diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index b28463858a..932fe610dd 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -72,6 +72,7 @@ enum ApiEndpoints { PUB_SUB = 'pub-sub', PUB_SUB_MESSAGES = 'pub-sub/messages', + CLUSTER_DETAILS = 'cluster-details', NOTIFICATIONS = 'notifications', NOTIFICATIONS_READ = 'notifications/read', diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 2302822b24..7a67015265 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -15,6 +15,8 @@ export enum PageNames { browser = 'browser', slowLog = 'slowlog', pubSub = 'pub-sub', + analytics = 'analytics', + clusterDetails = 'cluster-details', } const redisCloud = '/redis-cloud' @@ -34,6 +36,8 @@ export const Pages = { sentinelDatabasesResult: `${sentinel}/databases-result`, browser: (instanceId: string) => `/${instanceId}/${PageNames.browser}`, workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}`, - slowLog: (instanceId: string) => `/${instanceId}/${PageNames.slowLog}`, pubSub: (instanceId: string) => `/${instanceId}/${PageNames.pubSub}`, + analytics: (instanceId: string) => `/${instanceId}/${PageNames.analytics}`, + slowLog: (instanceId: string) => `/${instanceId}/${PageNames.analytics}/${PageNames.slowLog}`, + clusterDetails: (instanceId: string) => `/${instanceId}/${PageNames.analytics}/${PageNames.clusterDetails}`, } diff --git a/redisinsight/ui/src/mocks/handlers/analytics/clusterDetailsHandlers.ts b/redisinsight/ui/src/mocks/handlers/analytics/clusterDetailsHandlers.ts new file mode 100644 index 0000000000..b328a8e685 --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/analytics/clusterDetailsHandlers.ts @@ -0,0 +1,111 @@ +import { DatabaseInstanceResponse } from 'apiSrc/modules/instances/dto/database-instance.dto' +import { rest, RestHandler } from 'msw' +import { ClusterDetails, HealthStatus, NodeRole } from 'apiSrc/modules/cluster-monitor/models' +import { ApiEndpoints } from 'uiSrc/constants' +import { getUrl } from 'uiSrc/utils' +import { getMswURL } from 'uiSrc/utils/test-utils' + +export const INSTANCE_ID_MOCK = 'instanceId' + +const handlers: RestHandler[] = [ + // useGetClusterDetailsQuery + rest.get(getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.CLUSTER_DETAILS)), + async (_req, res, ctx) => res( + ctx.status(200), + ctx.json(CLUSTER_DETAILS_DATA_MOCK), + )) +] + +export const CLUSTER_DETAILS_DATA_MOCK: ClusterDetails = { + state: 'ok', + slotsAssigned: 16384, + slotsOk: 16384, + slotsPFail: 0, + slotsFail: 0, + slotsUnassigned: 0, + statsMessagesSent: 0, + statsMessagesReceived: 0, + currentEpoch: 0, + myEpoch: 0, + size: 3, + knownNodes: 3, + uptimeSec: 1661931600, + nodes: [ + { + id: '3', + host: '3.93.234.244', + port: 12511, + role: 'primary' as NodeRole, + slots: [ + '10923-16383' + ], + health: 'online' as HealthStatus, + totalKeys: 0, + usedMemory: 38448896, + opsPerSecond: 0, + connectionsReceived: 15, + connectedClients: 6, + commandsProcessed: 114, + networkInKbps: 0.35, + networkOutKbps: 3.62, + cacheHitRatio: 0, + replicationOffset: 0, + uptimeSec: 1661931600, + version: '6.2.6', + mode: 'standalone', + replicas: [] + }, + { + id: '4', + host: '44.202.117.57', + port: 12511, + role: 'primary' as NodeRole, + slots: [ + '0-5460' + ], + health: 'online' as HealthStatus, + totalKeys: 0, + usedMemory: 38448896, + opsPerSecond: 0, + connectionsReceived: 15, + connectedClients: 6, + commandsProcessed: 114, + networkInKbps: 0.35, + networkOutKbps: 3.62, + cacheHitRatio: 0, + replicationOffset: 0, + uptimeSec: 1661931600, + version: '6.2.6', + mode: 'standalone', + replicas: [] + }, + { + id: '5', + host: '44.210.115.34', + port: 12511, + role: 'primary' as NodeRole, + slots: [ + '5461-10922' + ], + health: 'online' as HealthStatus, + totalKeys: 0, + usedMemory: 38448896, + opsPerSecond: 0, + connectionsReceived: 15, + connectedClients: 6, + commandsProcessed: 114, + networkInKbps: 0.35, + networkOutKbps: 3.62, + cacheHitRatio: 0, + replicationOffset: 0, + uptimeSec: 1661931600, + version: '6.2.6', + mode: 'standalone', + replicas: [] + } + ], + version: '6.2.6', + mode: 'standalone' +} + +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/analytics/index.ts b/redisinsight/ui/src/mocks/handlers/analytics/index.ts new file mode 100644 index 0000000000..f548fd7655 --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/analytics/index.ts @@ -0,0 +1,6 @@ +import { DefaultBodyType, MockedRequest, RestHandler } from 'msw' + +import clusterDetails from './clusterDetailsHandlers' + +const handlers: RestHandler>[] = [].concat(clusterDetails) +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/index.ts b/redisinsight/ui/src/mocks/handlers/index.ts index 603bbcdf1e..519ee1acf3 100644 --- a/redisinsight/ui/src/mocks/handlers/index.ts +++ b/redisinsight/ui/src/mocks/handlers/index.ts @@ -2,5 +2,6 @@ import { MockedRequest, RestHandler } from 'msw' import instances from './instances' import content from './content' import app from './app' +import analytics from './analytics' -export const handlers: RestHandler[] = [].concat(instances, content, app) +export const handlers: RestHandler[] = [].concat(instances, content, app, analytics) diff --git a/redisinsight/ui/src/pages/analytics/AnalyticsPage.spec.tsx b/redisinsight/ui/src/pages/analytics/AnalyticsPage.spec.tsx new file mode 100644 index 0000000000..88a3081a03 --- /dev/null +++ b/redisinsight/ui/src/pages/analytics/AnalyticsPage.spec.tsx @@ -0,0 +1,28 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { BrowserRouter } from 'react-router-dom' +import { instance, mock } from 'ts-mockito' + +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import AnalyticsPage, { Props } from './AnalyticsPage' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('AnalyticsPage', () => { + it('should render', () => { + expect( + render( + + + + ) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/analytics/AnalyticsPage.tsx b/redisinsight/ui/src/pages/analytics/AnalyticsPage.tsx new file mode 100644 index 0000000000..ddb91161fd --- /dev/null +++ b/redisinsight/ui/src/pages/analytics/AnalyticsPage.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react' +import { useSelector } from 'react-redux' +import { useHistory, useParams, useLocation } from 'react-router-dom' +import { Pages } from 'uiSrc/constants' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { ConnectionType } from 'uiSrc/slices/interfaces' + +import AnalyticsPageRouter from './AnalyticsPageRouter' + +export interface Props { + routes: any[]; +} + +const AnalyticsPage = ({ routes = [] }: Props) => { + const history = useHistory() + const { instanceId } = useParams<{ instanceId: string }>() + const { pathname } = useLocation() + const { connectionType } = useSelector(connectedInstanceSelector) + + useEffect(() => { + if (pathname === Pages.analytics(instanceId)) { + history.push(connectionType === ConnectionType.Cluster + ? Pages.clusterDetails(instanceId) + : Pages.slowLog(instanceId)) + } + }, [connectionType, instanceId, pathname]) + + return ( + + ) +} + +export default AnalyticsPage diff --git a/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.spec.tsx b/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.spec.tsx new file mode 100644 index 0000000000..c9c68c1084 --- /dev/null +++ b/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.spec.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import AnalyticsPageRouter from './AnalyticsPageRouter' + +const mockedRoutes = [ + { + path: '/slowlog', + }, +] + +describe('AnalyticsPageRouter', () => { + it('should render', () => { + expect( + render(, { withRouter: true }) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.tsx b/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.tsx new file mode 100644 index 0000000000..ce61918b20 --- /dev/null +++ b/redisinsight/ui/src/pages/analytics/AnalyticsPageRouter.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Switch } from 'react-router-dom' +import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' + +export interface Props { + routes: any[]; +} +const InstancePageRouter = ({ routes }: Props) => ( + + {routes.map((route, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +) + +export default React.memo(InstancePageRouter) diff --git a/redisinsight/ui/src/pages/analytics/index.ts b/redisinsight/ui/src/pages/analytics/index.ts new file mode 100644 index 0000000000..6bc97d44b0 --- /dev/null +++ b/redisinsight/ui/src/pages/analytics/index.ts @@ -0,0 +1,6 @@ +import AnalyticsPage from './AnalyticsPage' +import AnalyticsPageRouter from './AnalyticsPageRouter' + +export { AnalyticsPage, AnalyticsPageRouter } + +export default AnalyticsPage diff --git a/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.spec.tsx b/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.spec.tsx new file mode 100644 index 0000000000..f7c081ce28 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.spec.tsx @@ -0,0 +1,36 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { CLUSTER_DETAILS_DATA_MOCK } from 'uiSrc/mocks/handlers/analytics/clusterDetailsHandlers' +import { + getClusterDetails, + getClusterDetailsSuccess +} from 'uiSrc/slices/analytics/clusterDetails' +import { act, cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' + +import ClusterDetailsPage from './ClusterDetailsPage' + +let store: typeof mockedStore + +describe('ClusterDetailsPage', () => { + beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() + }) + + it('should render', async () => { + await act(() => { + expect(render()) + .toBeTruthy() + }) + }) + + it('should call fetchClusterDetailsAction after rendering', async () => { + await act(() => { + render() + }) + + const expectedActions = [getClusterDetails(), getClusterDetailsSuccess(CLUSTER_DETAILS_DATA_MOCK)] + expect(store.getActions()).toEqual([...expectedActions]) + }) +}) diff --git a/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.tsx b/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.tsx new file mode 100644 index 0000000000..121729f742 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/ClusterDetailsPage.tsx @@ -0,0 +1,127 @@ +import { orderBy } from 'lodash' +import React, { useContext, useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useParams } from 'react-router-dom' +import { ClusterNodeDetails } from 'src/modules/cluster-monitor/models' + +import InstanceHeader from 'uiSrc/components/instance-header' +import { Theme } from 'uiSrc/constants' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { clusterDetailsSelector, fetchClusterDetailsAction } from 'uiSrc/slices/analytics/clusterDetails' +import { analyticsSettingsSelector, setAnalyticsViewTab } from 'uiSrc/slices/analytics/settings' +import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import { formatLongName, getDbIndex, getLetterByIndex, Nullable, setTitle, } from 'uiSrc/utils' +import { ColorScheme, getRGBColorByScheme, RGBColor } from 'uiSrc/utils/colors' + +import { ClusterDetailsHeader, ClusterDetailsGraphics, ClusterNodesTable } from './components' + +import styles from './styles.module.scss' + +export interface ModifiedClusterNodes extends ClusterNodeDetails { + letter: string + index: number + color: RGBColor +} + +const POLLING_INTERVAL = 5_000 + +const ClusterDetailsPage = () => { + let interval: NodeJS.Timeout + const { instanceId } = useParams<{ instanceId: string }>() + const { + db, + name: connectedInstanceName, + } = useSelector(connectedInstanceSelector) + const { viewTab } = useSelector(analyticsSettingsSelector) + const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) + const { loading, data } = useSelector(clusterDetailsSelector) + + const [isPageViewSent, setIsPageViewSent] = useState(false) + const [nodes, setNodes] = useState>(null) + + const dispatch = useDispatch() + const { theme } = useContext(ThemeContext) + + const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` + setTitle(`${dbName} - Overview`) + + const colorScheme: ColorScheme = { + cHueStart: 180, + cHueRange: 140, + cSaturation: 55, + cLightness: theme === Theme.Dark ? 45 : 55 + } + + useEffect(() => { + dispatch(fetchClusterDetailsAction( + instanceId, + () => {}, + () => clearInterval(interval) + )) + + if (viewTab !== AnalyticsViewTab.ClusterDetails) { + dispatch(setAnalyticsViewTab(AnalyticsViewTab.ClusterDetails)) + } + }, []) + + useEffect(() => { + if (!loading) { + interval = setInterval(() => { + if (document.hidden) return + + dispatch(fetchClusterDetailsAction( + instanceId, + () => {}, + () => clearInterval(interval) + )) + }, POLLING_INTERVAL) + } + return () => clearInterval(interval) + }, [instanceId, loading]) + + useEffect(() => { + if (data) { + const nodes = orderBy(data.nodes, ['asc', 'host']) + const shift = colorScheme.cHueRange / nodes.length + const modifiedNodes = nodes.map((d, index) => ({ + ...d, + letter: getLetterByIndex(index), + index, + color: getRGBColorByScheme(index, shift, colorScheme) + })) + setNodes(modifiedNodes) + } + }, [data]) + + useEffect(() => { + if (connectedInstanceName && !isPageViewSent && analyticsIdentified) { + sendPageView(instanceId) + } + }, [connectedInstanceName, isPageViewSent, analyticsIdentified]) + + const sendPageView = (instanceId: string) => { + sendPageViewTelemetry({ + name: TelemetryPageView.CLUSTER_DETAILS_PAGE, + databaseId: instanceId + }) + setIsPageViewSent(true) + } + + return ( + <> + +
+ +
+ + +
+
+ + ) +} + +export default ClusterDetailsPage diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.spec.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.spec.tsx new file mode 100644 index 0000000000..1d33d84e73 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.spec.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { ModifiedClusterNodes } from 'uiSrc/pages/clusterDetails/ClusterDetailsPage' +import { getLetterByIndex } from 'uiSrc/utils' +import { rgb } from 'uiSrc/utils/colors' +import { render, screen } from 'uiSrc/utils/test-utils' + +import ClusterNodesTable from './ClusterNodesTable' + +const mockNodes = [ + { + id: '1', + host: '0.0.0.1', + port: 6379, + role: 'primary', + slots: ['10923-16383'], + health: 'online', + totalKeys: 1, + usedMemory: 2867968, + opsPerSecond: 1, + connectionsReceived: 13, + connectedClients: 6, + commandsProcessed: 5678, + networkInKbps: 0.02, + networkOutKbps: 0, + cacheHitRatio: 1, + replicationOffset: 6924, + uptimeSec: 5614, + version: '6.2.6', + mode: 'cluster', + replicas: [] + }, + { + id: '2', + host: '0.0.0.2', + port: 6379, + role: 'primary', + slots: ['0-5460'], + health: 'online', + totalKeys: 4, + usedMemory: 2825880, + opsPerSecond: 1, + connectionsReceived: 15, + connectedClients: 4, + commandsProcessed: 5667, + networkInKbps: 0.04, + networkOutKbps: 0, + cacheHitRatio: 1, + replicationOffset: 6910, + uptimeSec: 5609, + version: '6.2.6', + mode: 'cluster', + replicas: [] + }, + { + id: '3', + host: '0.0.0.3', + port: 6379, + role: 'primary', + slots: [ + '5461-10922' + ], + health: 'online', + totalKeys: 10, + usedMemory: 2886960, + opsPerSecond: 0, + connectionsReceived: 18, + connectedClients: 7, + commandsProcessed: 5697, + networkInKbps: 0.02, + networkOutKbps: 0, + cacheHitRatio: 0, + replicationOffset: 6991, + uptimeSec: 5609, + version: '6.2.6', + mode: 'cluster', + replicas: [] + } +].map((d, index) => ({ ...d, letter: getLetterByIndex(index), index, color: [0, 0, 0] })) as ModifiedClusterNodes[] + +describe('ClusterNodesTable', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render loading content', () => { + render() + expect(screen.getByTestId('primary-nodes-table-loading')).toBeInTheDocument() + expect(screen.queryByTestId('primary-nodes-table')).not.toBeInTheDocument() + }) + + it('should render table', () => { + render() + expect(screen.getByTestId('primary-nodes-table')).toBeInTheDocument() + expect(screen.queryByTestId('primary-nodes-table-loading')).not.toBeInTheDocument() + }) + + it('should render table with 3 items', () => { + render() + expect(screen.getAllByTestId('node-letter')).toHaveLength(3) + }) + + it('should highlight max value for total keys', () => { + render() + expect(screen.getByTestId('totalKeys-value-max')).toHaveTextContent(mockNodes[2].totalKeys.toString()) + }) + + it('should not highlight max value for opsPerSecond with equals values', () => { + render() + expect(screen.queryByTestId('opsPerSecond-value-max')).not.toBeInTheDocument() + }) + + it('should render background color for each node', () => { + render() + mockNodes.forEach(({ letter, color }) => { + expect(screen.getByTestId(`node-color-${letter}`)).toHaveStyle({ 'background-color': rgb(color) }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.tsx new file mode 100644 index 0000000000..7224fa2397 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/ClusterNodesTable.tsx @@ -0,0 +1,203 @@ +import { + EuiBasicTableColumn, + EuiIcon, + EuiInMemoryTable, + EuiLoadingContent, + EuiToolTip, + PropertySort +} from '@elastic/eui' +import { IconType } from '@elastic/eui/src/components/icon/icon' +import cx from 'classnames' +import { map } from 'lodash' +import React, { useState } from 'react' +import { + InputIconSvg, + KeyIconSvg, + MemoryIconSvg, + OutputIconSvg, + UserIconSvg, + MeasureIconSvg +} from 'uiSrc/components/database-overview/components/icons' +import { ModifiedClusterNodes } from 'uiSrc/pages/clusterDetails/ClusterDetailsPage' +import { formatBytes, Nullable } from 'uiSrc/utils' +import { rgb } from 'uiSrc/utils/colors' +import { numberWithSpaces } from 'uiSrc/utils/numbers' + +import styles from './styles.module.scss' + +const ClusterNodesTable = ({ nodes, loading }: { nodes: Nullable, loading: boolean }) => { + const [sort, setSort] = useState({ field: 'host', direction: 'asc' }) + + const isMaxValue = (field: string, value: number) => { + const values = map(nodes, field) + return Math.max(...values) === value && values.filter((v) => v === value).length === 1 + } + + const headerIconTemplate = (label: string, icon: IconType) => ( +
+ + {label} +
+ ) + + const columns: EuiBasicTableColumn[] = [ + { + name: ( +
+ {`${nodes?.length} Primary nodes`} +
+ ), + field: 'host', + dataType: 'string', + sortable: ({ index }) => index, + render: (value: number, { letter, port, color }) => ( + <> +
+
+ {letter} + {value}:{port} +
+ + ) + }, + { + name: headerIconTemplate('Commands/s', MeasureIconSvg), + field: 'opsPerSecond', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const isMax = isMaxValue('opsPerSecond', value) + return ( + + {numberWithSpaces(value)} + + ) + } + }, + { + name: headerIconTemplate('Network Input', InputIconSvg), + field: 'networkInKbps', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const isMax = isMaxValue('networkInKbps', value) + return ( + <> + + {numberWithSpaces(value)} + + kb/s + + ) + } + }, + { + name: headerIconTemplate('Network Output', OutputIconSvg), + field: 'networkOutKbps', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const isMax = isMaxValue('networkOutKbps', value) + return ( + <> + + {numberWithSpaces(value)} + + kb/s + + ) + } + }, + { + name: headerIconTemplate('Total Memory', MemoryIconSvg), + field: 'usedMemory', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const [number, size] = formatBytes(value, 3, true) + const isMax = isMaxValue('usedMemory', value) + return ( + + <> + + {number} + + {size} + + + ) + } + }, + { + name: headerIconTemplate('Total Keys', KeyIconSvg), + field: 'totalKeys', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const isMax = isMaxValue('totalKeys', value) + return ( + + {numberWithSpaces(value)} + + ) + } + }, + { + name: ( +
+ + Clients +
+ ), + field: 'connectedClients', + width: '12%', + sortable: true, + align: 'right', + render: (value: number) => { + const isMax = isMaxValue('connectedClients', value) + return ( + + {numberWithSpaces(value)} + + ) + } + }, + ] + + return ( +
+ {(loading && !nodes) && ( +
+ +
+ )} + {nodes && ( +
+ setSort(sort)} + data-testid="primary-nodes-table" + /> +
+ )} +
+ ) +} + +export default ClusterNodesTable diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/index.ts b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/index.ts new file mode 100644 index 0000000000..36e8e9e491 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/index.ts @@ -0,0 +1,3 @@ +import ClusterNodesTable from './ClusterNodesTable' + +export default ClusterNodesTable diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/styles.module.scss b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/styles.module.scss new file mode 100644 index 0000000000..bd22e72f9a --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluser-nodes-table/styles.module.scss @@ -0,0 +1,183 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +$breakpoint-table: 1232px; + +.wrapper { + max-width: 1920px; + + .loading { + margin-top: 40px; + width: 100%; + + :global { + .euiLoadingContent__singleLine { + height: 36px; + + &:first-child { + height: 42px; + margin-bottom: 18px; + } + } + + .euiLoadingContent__singleLine:last-child:not(:only-child) { + width: 100%; + } + } + } +} + +.tableWrapper { + @include euiScrollBar; + + overflow: auto; + position: relative; + max-height: 100%; +} + +.table.tableNodes { + :global { + .euiTableHeaderCell { + min-width: 144px; + background-color: var(--euiColorEmptyShade); + + @media screen and (max-width: $breakpoint-table) { + min-width: 112px; + } + + .euiTableCellContent { + min-height: 78px; + padding: 12px 12px 18px 24px; + justify-content: flex-start; + align-items: flex-end; + + @media screen and (max-width: $breakpoint-table) { + padding: 12px 6px 18px 12px; + } + + &.euiTableCellContent--alignRight { + padding-left: 12px; + padding-right: 24px; + justify-content: flex-start; + align-items: flex-end; + flex-direction: row-reverse; + + @media screen and (max-width: $breakpoint-table) { + padding-left: 6px; + padding-right: 12px; + } + + .euiTableSortIcon { + margin-right: 4px; + margin-left: 0; + } + } + + &.euiTableCellContent--alignCenter { + justify-content: center; + + .euiTableSortIcon { + margin-right: 0; + margin-left: 4px; + } + } + + .euiTableCellContent__text { + font: normal normal normal 12px/18px Graphik, sans-serif; + } + } + + .euiTableHeaderButton { + border-bottom: 1px solid var(--euiColorLightShade); + outline: 1px solid var(--euiColorEmptyShade); + } + } + + .euiTableCellContent { + position: relative; + padding: 12px 12px 12px 24px; + font: normal normal 500 16px/18px Inconsolata; + + @media screen and (max-width: $breakpoint-table) { + padding: 12px 6px 12px 12px; + } + + &.euiTableCellContent--alignRight { + padding-left: 12px; + padding-right: 24px; + + @media screen and (max-width: $breakpoint-table) { + padding-left: 6px; + padding-right: 12px; + } + } + } + + .euiTableSortIcon { + width: 14px; + height: 14px; + margin-bottom: 2px; + fill: var(--htmlColor) !important; + } + + .euiTableHeaderButton.euiTableHeaderButton-isSorted { + span, div { + color: var(--htmlColor) !important; + } + } + + .euiTableHeaderButton:focus .euiTableCellContent__text { + text-decoration: none; + } + } + + :global(.euiTableCellContent) .maxValue { + color: var(--euiTooltipTitleTextColor); + font-weight: bold; + } + + :global(.euiTableHeaderButton.euiTableHeaderButton-isSorted) .headerIcon { + fill: var(--htmlColor) !important; + } + + .valueUnit { + font: normal normal normal 12px/18px Graphik, sans-serif !important; + margin-left: 4px; + color: var(--euiColorMediumShade) !important; + } + + .nodeName { + margin-right: 12px; + display: block; + min-width: 24px; + font: normal normal 500 13px/18px Graphik, sans-serif !important; + } + + .headerCell { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-end; + + .headerIcon { + fill: var(--textColorShade); + width: 24px; + height: 20px; + margin-bottom: 4px; + } + } + + .hostPort { + display: inline-flex; + font: normal normal normal 13px/18px Graphik, sans-serif !important; + } + + .nodeColor { + position: absolute; + left: 0; + top: 1px; + bottom: 1px; + width: 3px; + } +} diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.spec.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.spec.tsx new file mode 100644 index 0000000000..c666092bca --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { ModifiedClusterNodes } from 'uiSrc/pages/clusterDetails/ClusterDetailsPage' +import { render, screen } from 'uiSrc/utils/test-utils' + +import ClusterDetailsGraphics from './ClusterDetailsGraphics' + +const mockNodes = [ + { + id: '1', + host: '0.0.0.1', + port: 6379, + role: 'primary', + slots: ['10923-16383'], + health: 'online', + totalKeys: 1, + usedMemory: 2867968, + opsPerSecond: 1, + connectionsReceived: 13, + connectedClients: 6, + commandsProcessed: 5678, + networkInKbps: 0.02, + networkOutKbps: 0, + cacheHitRatio: 1, + replicationOffset: 6924, + uptimeSec: 5614, + version: '6.2.6', + mode: 'cluster', + replicas: [] + }, + { + id: '2', + host: '0.0.0.2', + port: 6379, + role: 'primary', + slots: ['0-5460'], + health: 'online', + totalKeys: 4, + usedMemory: 2825880, + opsPerSecond: 1, + connectionsReceived: 15, + connectedClients: 4, + commandsProcessed: 5667, + networkInKbps: 0.04, + networkOutKbps: 0, + cacheHitRatio: 1, + replicationOffset: 6910, + uptimeSec: 5609, + version: '6.2.6', + mode: 'cluster', + replicas: [] + }, + { + id: '3', + host: '0.0.0.3', + port: 6379, + role: 'primary', + slots: [ + '5461-10922' + ], + health: 'online', + totalKeys: 10, + usedMemory: 2886960, + opsPerSecond: 0, + connectionsReceived: 18, + connectedClients: 7, + commandsProcessed: 5697, + networkInKbps: 0.02, + networkOutKbps: 0, + cacheHitRatio: 0, + replicationOffset: 6991, + uptimeSec: 5609, + version: '6.2.6', + mode: 'cluster', + replicas: [] + } +].map((d, index) => ({ ...d, letter: 'A', index, color: [0, 0, 0] })) as ModifiedClusterNodes[] + +describe('ClusterDetailsGraphics', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render nothing without nodes', () => { + render() + expect(screen.queryByTestId('cluster-details-graphics-loading')).not.toBeInTheDocument() + expect(screen.queryByTestId('cluster-details-charts')).not.toBeInTheDocument() + }) + + it('should render loading content', () => { + render() + expect(screen.getByTestId('cluster-details-graphics-loading')).toBeInTheDocument() + expect(screen.queryByTestId('cluster-details-charts')).not.toBeInTheDocument() + }) + + it('should render donuts', () => { + render() + expect(screen.getByTestId('donut-memory')).toBeInTheDocument() + expect(screen.queryByTestId('donut-keys')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.tsx new file mode 100644 index 0000000000..9ca13fe647 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/ClusterDetailsGraphics.tsx @@ -0,0 +1,73 @@ +import { EuiIcon, EuiTitle } from '@elastic/eui' +import cx from 'classnames' +import React, { useEffect, useState } from 'react' +import { DonutChart } from 'uiSrc/components/charts' +import { ChartData } from 'uiSrc/components/charts/donut-chart/DonutChart' +import { KeyIconSvg, MemoryIconSvg } from 'uiSrc/components/database-overview/components/icons' +import { ModifiedClusterNodes } from 'uiSrc/pages/clusterDetails/ClusterDetailsPage' +import { formatBytes, Nullable } from 'uiSrc/utils' +import { numberWithSpaces } from 'uiSrc/utils/numbers' + +import styles from './styles.module.scss' + +const ClusterDetailsGraphics = ({ nodes, loading }: { nodes: Nullable, loading: boolean }) => { + const [memoryData, setMemoryData] = useState([]) + const [keysData, setKeysData] = useState([]) + + const renderMemoryLabel = (value: number) => formatBytes(value, 1, false) as string + const renderMemoryTooltip = (value: number) => `${numberWithSpaces(value)} B` + + useEffect(() => { + if (nodes) { + setMemoryData(nodes.map((n) => ({ value: n.usedMemory, name: n.letter, color: n.color })) as ChartData[]) + setKeysData(nodes.map((n) => ({ value: n.totalKeys, name: n.letter, color: n.color })) as ChartData[]) + } + }, [nodes]) + + if (loading && !nodes?.length) { + return ( +
+
+
+
+ ) + } + + if (!nodes || nodes.length === 0) { + return null + } + + return ( +
+ + + + Memory + +
+ )} + /> + + + + Keys + +
+ )} + /> +
+ ) +} + +export default ClusterDetailsGraphics diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/index.ts b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/index.ts new file mode 100644 index 0000000000..316d8707fc --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/index.ts @@ -0,0 +1,3 @@ +import ClusterDetailsGraphics from './ClusterDetailsGraphics' + +export default ClusterDetailsGraphics diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/styles.module.scss b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/styles.module.scss new file mode 100644 index 0000000000..3b587cd5b4 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-graphics/styles.module.scss @@ -0,0 +1,30 @@ +.wrapper { + background-color: var(--euiColorLightestShade); + border-radius: 16px; + + display: flex; + align-items: center; + justify-content: space-around; + margin-bottom: 24px; + + &.loadingWrapper { + margin-top: 36px; + } + + .chartTitle { + display: flex; + align-items: center; + + .icon { + margin-right: 10px; + } + } + + .preloaderCircle { + width: 180px; + height: 180px; + margin: 60px 0; + border-radius: 100%; + background-color: var(--separatorColor); + } +} diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.spec.tsx new file mode 100644 index 0000000000..edcc58d044 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { clusterDetailsSelector } from 'uiSrc/slices/analytics/clusterDetails' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { render, screen } from 'uiSrc/utils/test-utils' + +import ClusterDetailsHeader from './ClusterDetailsHeader' + +jest.mock('uiSrc/slices/analytics/clusterDetails', () => ({ + ...jest.requireActual('uiSrc/slices/analytics/clusterDetails'), + clusterDetailsSelector: jest.fn().mockReturnValue({ + data: null, + loading: false, + error: '', + }), +})) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + username: '', + }), +})) + +describe('ClusterDetailsHeader', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render "EuiLoadingContent" until loading and no data', () => { + (clusterDetailsSelector as jest.Mock).mockImplementation(() => ({ + data: null, + loading: true, + error: '' + })) + + render() + + expect(screen.getByTestId('cluster-details-loading')).toBeInTheDocument() + }) + it('should render "cluster-details-content" after loading and with data', () => { + (clusterDetailsSelector as jest.Mock).mockImplementation(() => ({ + data: { version: '111' }, + loading: false, + error: '' + })) + + const { queryByTestId } = render() + + expect(queryByTestId('cluster-details-loading')).not.toBeInTheDocument() + expect(queryByTestId('cluster-details-username')).not.toBeInTheDocument() + expect(queryByTestId('cluster-details-content')).toBeInTheDocument() + }) + + it('huge username should be truncated', () => { + (clusterDetailsSelector as jest.Mock).mockImplementation(() => ({ + data: { version: '111' }, + loading: false, + error: '' + })); + + (connectedInstanceSelector as jest.Mock).mockImplementation(() => ({ + username: Array.from({ length: 50 }).fill('test').join('') + })) + + const { queryByTestId } = render() + + expect(queryByTestId('cluster-details-username')).toBeInTheDocument() + }) + + it.skip('uptime should be with truncated to first unit', () => { + (clusterDetailsSelector as jest.Mock).mockImplementation(() => ({ + data: { uptimeSec: 11111 }, + loading: false, + error: '' + })) + + const { queryByTestId } = render() + + expect(queryByTestId('cluster-details-uptime')).toHaveTextContent('3 h') + }) +}) diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.tsx b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.tsx new file mode 100644 index 0000000000..5a4d0c986c --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/ClusterDetailsHeader.tsx @@ -0,0 +1,115 @@ +import { + EuiLoadingContent, + EuiText, + EuiToolTip, +} from '@elastic/eui' +import React from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { capitalize } from 'lodash' + +import { + truncateNumberToFirstUnit, + formatLongName, + truncateNumberToDuration, +} from 'uiSrc/utils' +import { nullableNumberWithSpaces } from 'uiSrc/utils/numbers' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { ConnectionType, CONNECTION_TYPE_DISPLAY } from 'uiSrc/slices/interfaces' +import AnalyticsTabs from 'uiSrc/components/analytics-tabs' +import { clusterDetailsSelector } from 'uiSrc/slices/analytics/clusterDetails' + +import styles from './styles.module.scss' + +interface IMetrics { + label: string + value: any + border?: 'left' +} + +const MAX_NAME_LENGTH = 30 +const DEFAULT_USERNAME = 'Default' + +const ClusterDetailsHeader = () => { + const { + username = DEFAULT_USERNAME, + connectionType = ConnectionType.Cluster, + } = useSelector(connectedInstanceSelector) + + const { + data, + loading, + } = useSelector(clusterDetailsSelector) + + const metrics: IMetrics[] = [{ + label: 'Type', + value: CONNECTION_TYPE_DISPLAY[connectionType], + }, { + label: 'Version', + value: data?.version || '', + }, { + label: 'User', + value: (username || DEFAULT_USERNAME)?.length < MAX_NAME_LENGTH + ? (username || DEFAULT_USERNAME) + : ( + + {formatLongName(username || DEFAULT_USERNAME)} + + )} + > +
{formatLongName(username || DEFAULT_USERNAME, MAX_NAME_LENGTH, 5)}
+
+ ), + }, { + label: 'Uptime', + border: 'left', + value: ( + + {`${nullableNumberWithSpaces(data?.uptimeSec) || 0} s`} +
+ {`(${truncateNumberToDuration(data?.uptimeSec || 0)})`} + + )} + > +
{truncateNumberToFirstUnit(data?.uptimeSec || 0)}
+
+ ) + }] + + return ( +
+ + + {loading && !data && ( +
+ +
+ )} + {data && ( +
+ {metrics.map(({ value, label, border }) => ( +
+ {value} + {label} +
+ ))} +
+ )} +
+ ) +} + +export default ClusterDetailsHeader diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/index.ts b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/index.ts new file mode 100644 index 0000000000..e39d5afe78 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/index.ts @@ -0,0 +1,3 @@ +import ClusterDetailsHeader from './ClusterDetailsHeader' + +export default ClusterDetailsHeader diff --git a/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/styles.module.scss b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/styles.module.scss new file mode 100644 index 0000000000..ac015f280f --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/cluster-details-header/styles.module.scss @@ -0,0 +1,43 @@ +.container { + background-color: var(--euiColorEmptyShade); + max-width: 960px; +} + +.content { + padding-top: 24px; + padding-bottom: 36px; + display: flex; + + .label { + font-size: 12px !important; + line-height: 18px !important; + padding-bottom: 1px; + color: var(--euiColorMediumShade) !important; + } +} + +.item { + padding-right: 24px; +} + +.value { + height: 22px; + font-size: 18px !important; + line-height: 18px !important; + font-weight: 500 !important; +} + +.loading { + width: 422px; + padding-top: 30px; + + :global(.euiLoadingContent__singleLine:last-child:not(:only-child)) { + width: 75% !important; + height: 12px !important; + } +} + +.borderLeft { + border-left: 2px solid var(--separatorColorLight); + padding-left: 24px; +} diff --git a/redisinsight/ui/src/pages/clusterDetails/components/index.ts b/redisinsight/ui/src/pages/clusterDetails/components/index.ts new file mode 100644 index 0000000000..a3332921e8 --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/components/index.ts @@ -0,0 +1,9 @@ +import ClusterDetailsHeader from './cluster-details-header' +import ClusterDetailsGraphics from './cluster-details-graphics' +import ClusterNodesTable from './cluser-nodes-table' + +export { + ClusterDetailsHeader, + ClusterDetailsGraphics, + ClusterNodesTable +} diff --git a/redisinsight/ui/src/pages/clusterDetails/index.ts b/redisinsight/ui/src/pages/clusterDetails/index.ts new file mode 100644 index 0000000000..d8f15ce35f --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/index.ts @@ -0,0 +1,3 @@ +import ClusterDetailsPage from './ClusterDetailsPage' + +export default ClusterDetailsPage diff --git a/redisinsight/ui/src/pages/clusterDetails/styles.module.scss b/redisinsight/ui/src/pages/clusterDetails/styles.module.scss new file mode 100644 index 0000000000..8bd111291a --- /dev/null +++ b/redisinsight/ui/src/pages/clusterDetails/styles.module.scss @@ -0,0 +1,19 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.main { + margin: 0 16px 0; + height: calc(100% - 70px); + background-color: var(--euiColorEmptyShade); + padding: 18px 24px; +} + +.wrapper { + @include euiScrollBar; + overflow-y: auto; + overflow-x: hidden; + max-height: calc(100% - 134px); + + max-width: 1920px; +} diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss index 12c4c8a076..3422b0bbf0 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss @@ -131,3 +131,11 @@ $breakpoint-l: 1400px; height: 24px; color: var(--htmlColor) !important; } + +.actionDeleteBtn { + min-width: 93px !important; + + &:focus { + text-decoration: none !important; + } +} diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index eb0f7d34b7..250222ba46 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -22,6 +22,7 @@ import BottomGroupComponents from 'uiSrc/components/bottom-group-components/Bott import { monitorSelector, setMonitorInitialState } from 'uiSrc/slices/cli/monitor' import { setInitialPubSubState } from 'uiSrc/slices/pubsub/pubsub' import { setBulkActionsInitialState } from 'uiSrc/slices/browser/bulkActions' +import { setClusterDetailsInitialState } from 'uiSrc/slices/analytics/clusterDetails' import InstancePageRouter from './InstancePageRouter' import styles from './styles.module.scss' @@ -91,6 +92,7 @@ const InstancePage = ({ routes = [] }: Props) => { dispatch(setBulkActionsInitialState()) dispatch(setAppContextInitialState()) dispatch(resetKeysData()) + dispatch(setClusterDetailsInitialState()) setTimeout(() => { dispatch(resetOutput()) }, 0) diff --git a/redisinsight/ui/src/pages/slowLog/SlowLogPage.spec.tsx b/redisinsight/ui/src/pages/slowLog/SlowLogPage.spec.tsx index 8b11e8b73c..9a90d17ee3 100644 --- a/redisinsight/ui/src/pages/slowLog/SlowLogPage.spec.tsx +++ b/redisinsight/ui/src/pages/slowLog/SlowLogPage.spec.tsx @@ -1,11 +1,11 @@ import React from 'react' -import { slowLogSelector } from 'uiSrc/slices/slowlog/slowlog' +import { slowLogSelector } from 'uiSrc/slices/analytics/slowlog' import { render, screen } from 'uiSrc/utils/test-utils' import SlowLogPage from './SlowLogPage' -jest.mock('uiSrc/slices/slowlog/slowlog', () => ({ - ...jest.requireActual('uiSrc/slices/slowlog/slowlog'), +jest.mock('uiSrc/slices/analytics/slowlog', () => ({ + ...jest.requireActual('uiSrc/slices/analytics/slowlog'), slowLogSelector: jest.fn().mockReturnValue({ data: [], config: null, diff --git a/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx b/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx index 6840bbb59b..c09d490aa6 100644 --- a/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx +++ b/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx @@ -26,12 +26,16 @@ import { getSlowLogConfigAction, slowLogConfigSelector, slowLogSelector -} from 'uiSrc/slices/slowlog/slowlog' +} from 'uiSrc/slices/analytics/slowlog' import { sendPageViewTelemetry, sendEventTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' import { numberWithSpaces } from 'uiSrc/utils/numbers' +import AnalyticsTabs from 'uiSrc/components/analytics-tabs' +import { analyticsSettingsSelector, setAnalyticsViewTab } from 'uiSrc/slices/analytics/settings' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' import { SlowLog } from 'apiSrc/modules/slow-log/models' + import { EmptySlowLog, SlowLogTable, Actions } from './components' import styles from './styles.module.scss' @@ -52,6 +56,7 @@ const SlowLogPage = () => { const { data, loading, durationUnit, config } = useSelector(slowLogSelector) const { slowlogLogSlowerThan = 0, slowlogMaxLen } = useSelector(slowLogConfigSelector) const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) + const { viewTab } = useSelector(analyticsSettingsSelector) const { instanceId } = useParams<{ instanceId: string }>() const [count, setCount] = useState(DEFAULT_COUNT_VALUE) @@ -65,6 +70,9 @@ const SlowLogPage = () => { useEffect(() => { getConfig() + if (viewTab !== AnalyticsViewTab.SlowLog) { + dispatch(setAnalyticsViewTab(AnalyticsViewTab.SlowLog)) + } }, []) useEffect(() => { @@ -126,9 +134,12 @@ const SlowLogPage = () => {
- -

Slow Log

-
+ {connectionType === ConnectionType.Cluster && } + {connectionType !== ConnectionType.Cluster && ( + +

Slow Log

+
+ )}
diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx index 39744ae66e..431ed26fe1 100644 --- a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx @@ -15,7 +15,7 @@ import cx from 'classnames' import { useParams } from 'react-router-dom' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { DurationUnits } from 'uiSrc/constants' -import { slowLogSelector } from 'uiSrc/slices/slowlog/slowlog' +import { slowLogSelector } from 'uiSrc/slices/analytics/slowlog' import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' import { Nullable } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx index 71d70a44b8..0b8379c301 100644 --- a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx @@ -18,8 +18,8 @@ beforeEach(() => { store.clearActions() }) -jest.mock('uiSrc/slices/slowlog/slowlog', () => ({ - ...jest.requireActual('uiSrc/slices/slowlog/slowlog'), +jest.mock('uiSrc/slices/analytics/slowlog', () => ({ + ...jest.requireActual('uiSrc/slices/analytics/slowlog'), slowLogConfigSelector: jest.fn().mockReturnValue({ slowlogMaxLen: slowlogMaxLenMock, slowlogLogSlowerThan: slowlogLogSlowerThanMock, diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx index 72f71bc26a..56b137d720 100644 --- a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx @@ -24,7 +24,7 @@ import { import { ConnectionType } from 'uiSrc/slices/interfaces' import { ConfigDBStorageItem } from 'uiSrc/constants/storage' import { setDBConfigStorageField } from 'uiSrc/services' -import { patchSlowLogConfigAction, slowLogConfigSelector, slowLogSelector } from 'uiSrc/slices/slowlog/slowlog' +import { patchSlowLogConfigAction, slowLogConfigSelector, slowLogSelector } from 'uiSrc/slices/analytics/slowlog' import { errorValidateNegativeInteger, validateNumber } from 'uiSrc/utils' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { numberWithSpaces } from 'uiSrc/utils/numbers' diff --git a/redisinsight/ui/src/pages/slowLog/styles.module.scss b/redisinsight/ui/src/pages/slowLog/styles.module.scss index ace7865e25..eabb8ac342 100644 --- a/redisinsight/ui/src/pages/slowLog/styles.module.scss +++ b/redisinsight/ui/src/pages/slowLog/styles.module.scss @@ -2,7 +2,7 @@ margin: 0 16px 0; height: calc(100% - 70px); background-color: var(--euiColorEmptyShade); - padding: 24px 18px; + padding: 18px 24px; .title { font-size: 16px; @@ -10,7 +10,8 @@ } .actionsLine { - margin-bottom: 12px; + margin-top: 6px; + margin-bottom: 6px; } .countSelectWrapper { @@ -18,9 +19,15 @@ } .countSelect { - width: 86px; + min-width: 58px; + max-width: 84px; height: 30px; padding-left: 12px; padding-right: 32px; + border: none !important; + + & ~ :global(.euiFormControlLayoutIcons) svg { + width: 12px; + } } } diff --git a/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx b/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx index 2663703e37..75ec496cc8 100644 --- a/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx +++ b/redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx @@ -129,14 +129,12 @@ const ModuleNotLoaded = ({ content = {} }: Props) => {
{(!!summaryImgPath || !!summaryImgDark || !!summaryImgLight) && ( -
- redisearch table -
+ redisearch table )} {!!summaryText &&
{parse(summaryText)}
}
diff --git a/redisinsight/ui/src/slices/analytics/clusterDetails.ts b/redisinsight/ui/src/slices/analytics/clusterDetails.ts new file mode 100644 index 0000000000..1d02328c04 --- /dev/null +++ b/redisinsight/ui/src/slices/analytics/clusterDetails.ts @@ -0,0 +1,79 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AxiosError } from 'axios' +import { ApiEndpoints, } from 'uiSrc/constants' +import { apiService, } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { StateClusterDetails } from 'uiSrc/slices/interfaces/analytics' +import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' + +import { ClusterDetails } from 'apiSrc/modules/cluster-monitor/models/cluster-details' +import { AppDispatch, RootState } from '../store' + +export const initialState: StateClusterDetails = { + loading: false, + error: '', + data: null, +} + +const clusterDetailsSlice = createSlice({ + name: 'clusterDetails', + initialState, + reducers: { + setClusterDetailsInitialState: () => initialState, + getClusterDetails: (state) => { + state.loading = true + }, + getClusterDetailsSuccess: (state, { payload }: PayloadAction) => { + state.loading = false + state.data = payload + }, + getClusterDetailsError: (state, { payload }) => { + state.loading = false + state.error = payload + }, + } +}) + +export const clusterDetailsSelector = (state: RootState) => state.analytics.clusterDetails + +export const { + setClusterDetailsInitialState, + getClusterDetails, + getClusterDetailsSuccess, + getClusterDetailsError, +} = clusterDetailsSlice.actions + +// The reducer +export default clusterDetailsSlice.reducer + +// Asynchronous thunk action +export function fetchClusterDetailsAction( + instanceId: string, + onSuccessAction?: (data: ClusterDetails) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getClusterDetails()) + + const { data, status } = await apiService.get( + getUrl( + instanceId, + ApiEndpoints.CLUSTER_DETAILS + ) + ) + + if (isStatusSuccessful(status)) { + dispatch(getClusterDetailsSuccess(data)) + + onSuccessAction?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getClusterDetailsError(errorMessage)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/analytics/settings.ts b/redisinsight/ui/src/slices/analytics/settings.ts new file mode 100644 index 0000000000..0b6068f3f4 --- /dev/null +++ b/redisinsight/ui/src/slices/analytics/settings.ts @@ -0,0 +1,28 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AnalyticsViewTab, StateAnalyticsSettings } from 'uiSrc/slices/interfaces/analytics' +import { RootState } from 'uiSrc/slices/store' + +export const initialState: StateAnalyticsSettings = { + viewTab: AnalyticsViewTab.ClusterDetails, +} + +const analyticsSettings = createSlice({ + name: 'analyticsSettings', + initialState, + reducers: { + setInitialAnalyticsSettings: () => initialState, + + setAnalyticsViewTab: (state, { payload }: PayloadAction) => { + state.viewTab = payload + }, + } +}) + +export const { + setInitialAnalyticsSettings, + setAnalyticsViewTab, +} = analyticsSettings.actions + +export const analyticsSettingsSelector = (state: RootState) => state.analytics.settings + +export default analyticsSettings.reducer diff --git a/redisinsight/ui/src/slices/slowlog/slowlog.ts b/redisinsight/ui/src/slices/analytics/slowlog.ts similarity index 97% rename from redisinsight/ui/src/slices/slowlog/slowlog.ts rename to redisinsight/ui/src/slices/analytics/slowlog.ts index 9d78d53e98..63b07bf6ee 100644 --- a/redisinsight/ui/src/slices/slowlog/slowlog.ts +++ b/redisinsight/ui/src/slices/analytics/slowlog.ts @@ -3,7 +3,7 @@ import { AxiosError } from 'axios' import { ApiEndpoints, DEFAULT_SLOWLOG_DURATION_UNIT, DurationUnits } from 'uiSrc/constants' import { apiService, getDBConfigStorageField } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' -import { StateSlowLog } from 'uiSrc/slices/interfaces/slowlog' +import { StateSlowLog } from 'uiSrc/slices/interfaces/analytics' import { ConfigDBStorageItem } from 'uiSrc/constants/storage' import { getApiErrorMessage, getUrl, isStatusSuccessful, Nullable } from 'uiSrc/utils' import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' @@ -72,8 +72,8 @@ const slowLogSlice = createSlice({ } }) -export const slowLogSelector = (state: RootState) => state.slowlog -export const slowLogConfigSelector = (state: RootState) => state.slowlog.config || {} +export const slowLogSelector = (state: RootState) => state.analytics.slowlog +export const slowLogConfigSelector = (state: RootState) => state.analytics.slowlog.config || {} export const { setSlowLogInitialState, diff --git a/redisinsight/ui/src/slices/interfaces/analytics.ts b/redisinsight/ui/src/slices/interfaces/analytics.ts new file mode 100644 index 0000000000..b129e06354 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/analytics.ts @@ -0,0 +1,28 @@ +import { DurationUnits } from 'uiSrc/constants' +import { Nullable } from 'uiSrc/utils' +import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' +import { ClusterDetails } from 'apiSrc/modules/cluster-monitor/models/cluster-details' + +export interface StateSlowLog { + loading: boolean + error: string + data: SlowLog[] + lastRefreshTime: Nullable, + config: Nullable, + durationUnit: DurationUnits +} + +export interface StateClusterDetails { + loading: boolean + error: string + data: Nullable +} + +export interface StateAnalyticsSettings { + viewTab: AnalyticsViewTab +} + +export enum AnalyticsViewTab { + ClusterDetails = 'ClusterDetails', + SlowLog = 'SlowLog', +} diff --git a/redisinsight/ui/src/slices/interfaces/slowlog.ts b/redisinsight/ui/src/slices/interfaces/slowlog.ts deleted file mode 100644 index bde1f4ee50..0000000000 --- a/redisinsight/ui/src/slices/interfaces/slowlog.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' -import { DurationUnits } from 'uiSrc/constants' -import { Nullable } from 'uiSrc/utils' - -export interface StateSlowLog { - loading: boolean - error: string - data: SlowLog[] - lastRefreshTime: Nullable, - config: Nullable, - durationUnit: DurationUnits -} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index c6165994ab..2a923f4c23 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -30,8 +30,10 @@ import workbenchResultsReducer from './workbench/wb-results' import workbenchGuidesReducer from './workbench/wb-guides' import workbenchTutorialsReducer from './workbench/wb-tutorials' import contentCreateRedisButtonReducer from './content/create-redis-buttons' -import slowLogReducer from './slowlog/slowlog' import pubSubReducer from './pubsub/pubsub' +import slowLogReducer from './analytics/slowlog' +import analyticsSettingsReducer from './analytics/settings' +import clusterDetailsReducer from './analytics/clusterDetails' export const history = createBrowserHistory() @@ -79,7 +81,11 @@ export const rootReducer = combineReducers({ content: combineReducers({ createRedisButtons: contentCreateRedisButtonReducer, }), - slowlog: slowLogReducer, + analytics: combineReducers({ + settings: analyticsSettingsReducer, + slowlog: slowLogReducer, + clusterDetails: clusterDetailsReducer, + }), pubsub: pubSubReducer, }) diff --git a/redisinsight/ui/src/slices/tests/analytics/clusterDetails.spec.ts b/redisinsight/ui/src/slices/tests/analytics/clusterDetails.spec.ts new file mode 100644 index 0000000000..b81560bce7 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/analytics/clusterDetails.spec.ts @@ -0,0 +1,167 @@ +import { cloneDeep } from 'lodash' +import { AxiosError } from 'axios' +import { apiService } from 'uiSrc/services' +import { cleanup, mockedStore, initialStateDefault } from 'uiSrc/utils/test-utils' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' + +import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' +import { CLUSTER_DETAILS_DATA_MOCK, INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/analytics/clusterDetailsHandlers' +import reducer, { + initialState, + getClusterDetails, + getClusterDetailsSuccess, + getClusterDetailsError, + clusterDetailsSelector, + setClusterDetailsInitialState, + fetchClusterDetailsAction, +} from '../../analytics/clusterDetails' + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('clusterDetails slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('setUserSettingsInitialState', () => { + it('should properly set the initial state', () => { + // Arrange + const state = { + ...initialState + } + + // Act + const nextState = reducer(initialState, setClusterDetailsInitialState()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { clusterDetails: nextState }, + }) + expect(clusterDetailsSelector(rootState)).toEqual(state) + }) + }) + + describe('getClusterDetails', () => { + it('should properly set state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true + } + + // Act + const nextState = reducer(initialState, getClusterDetails()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { clusterDetails: nextState }, + }) + expect(clusterDetailsSelector(rootState)).toEqual(state) + }) + }) + + describe('getClusterDetailsSuccess', () => { + it('should properly set state after success fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: false, + data: CLUSTER_DETAILS_DATA_MOCK, + } + + // Act + const nextState = reducer(initialState, getClusterDetailsSuccess(CLUSTER_DETAILS_DATA_MOCK)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { clusterDetails: nextState }, + }) + expect(clusterDetailsSelector(rootState)).toEqual(state) + }) + }) + + describe('getClusterDetailsError', () => { + it('should properly set state after failed fetch data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error + } + + // Act + const nextState = reducer(initialState, getClusterDetailsError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { clusterDetails: nextState }, + }) + expect(clusterDetailsSelector(rootState)).toEqual(state) + }) + }) + + // thunks + describe('thunks', () => { + describe('fetchClusterDetailsAction', () => { + it('succeed to fetch data', async () => { + // Act + + const responsePayload = { + status: 200, + data: CLUSTER_DETAILS_DATA_MOCK + } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + await store.dispatch(fetchClusterDetailsAction(INSTANCE_ID_MOCK)) + + // Assert + const expectedActions = [ + getClusterDetails(), + getClusterDetailsSuccess(CLUSTER_DETAILS_DATA_MOCK), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const responsePayload = { + response: { + status: 500, + data: { message: DEFAULT_ERROR_MESSAGE }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchClusterDetailsAction(INSTANCE_ID_MOCK)) + + // Assert + const expectedActions = [ + getClusterDetails(), + addErrorNotification(responsePayload as AxiosError), + getClusterDetailsError(DEFAULT_ERROR_MESSAGE) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/slices/tests/analytics/settings.spec.ts b/redisinsight/ui/src/slices/tests/analytics/settings.spec.ts new file mode 100644 index 0000000000..626d5e66d9 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/analytics/settings.spec.ts @@ -0,0 +1,42 @@ +import { initialStateDefault } from 'uiSrc/utils/test-utils' +import { AnalyticsViewTab } from 'uiSrc/slices/interfaces/analytics' + +import reducer, { + analyticsSettingsSelector, + initialState, + setAnalyticsViewTab, +} from '../../analytics/settings' + +describe('analytics settings slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('setAnalyticsViewTab', () => { + it('should properly set the AnalyticsViewTab.SlowLog', () => { + // Arrange + const state = { + ...initialState, + viewTab: AnalyticsViewTab.SlowLog + } + + // Act + const nextState = reducer(initialState, setAnalyticsViewTab(AnalyticsViewTab.SlowLog)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { settings: nextState }, + }) + expect(analyticsSettingsSelector(rootState)).toEqual(state) + }) + }) +}) diff --git a/redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts b/redisinsight/ui/src/slices/tests/analytics/slowlog.spec.ts similarity index 95% rename from redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts rename to redisinsight/ui/src/slices/tests/analytics/slowlog.spec.ts index a6cef02ba9..e43759d067 100644 --- a/redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts +++ b/redisinsight/ui/src/slices/tests/analytics/slowlog.spec.ts @@ -1,12 +1,12 @@ import { cloneDeep } from 'lodash' import { AxiosError } from 'axios' -import { SlowLog, SlowLogConfig } from 'src/modules/slow-log/models' import { DEFAULT_SLOWLOG_DURATION_UNIT } from 'uiSrc/constants' - import { apiService } from 'uiSrc/services' import { cleanup, mockedStore, initialStateDefault } from 'uiSrc/utils/test-utils' import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' + import reducer, { initialState, getSlowLogConfig, @@ -24,7 +24,7 @@ import reducer, { patchSlowLogConfigAction, setSlowLogInitialState, slowLogSelector -} from '../../slowlog/slowlog' +} from '../../analytics/slowlog' const timestamp = 1629128049027 let store: typeof mockedStore @@ -70,7 +70,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -89,7 +89,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -120,7 +120,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -141,7 +141,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -160,7 +160,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -180,7 +180,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -201,7 +201,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -220,7 +220,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -244,7 +244,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) @@ -265,7 +265,7 @@ describe('slowLog slice', () => { // Assert const rootState = Object.assign(initialStateDefault, { - slowlog: nextState, + analytics: { slowlog: nextState }, }) expect(slowLogSelector(rootState)).toEqual(state) }) diff --git a/redisinsight/ui/src/slices/tests/app/info.spec.ts b/redisinsight/ui/src/slices/tests/app/info.spec.ts index 650680413b..44b7f9268d 100644 --- a/redisinsight/ui/src/slices/tests/app/info.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/info.spec.ts @@ -10,6 +10,7 @@ import { apiService } from 'uiSrc/services' import { mswServer } from 'uiSrc/mocks/server' import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' +import { APP_INFO_DATA_MOCK } from 'uiSrc/mocks/handlers/app/infoHandlers' import reducer, { initialState, setAnalyticsIdentified, @@ -142,22 +143,14 @@ describe('slices', () => { describe('getServerInfoSuccess', () => { it('should properly set state after success', () => { - // Arrange - const data = { - id: 'id1', - createDateTime: '2000-01-01T00:00:00.000Z', - appVersion: '2.0.0', - osPlatform: 'win32', - buildType: 'ELECTRON' - } const state = { ...initialState, loading: false, - server: data + server: APP_INFO_DATA_MOCK } // Act - const nextState = reducer(initialState, getServerInfoSuccess(data)) + const nextState = reducer(initialState, getServerInfoSuccess(APP_INFO_DATA_MOCK)) // Assert const rootState = Object.assign(initialStateDefault, { @@ -193,25 +186,13 @@ describe('slices', () => { // thunks describe('fetchServerInfo', () => { it('succeed to fetch server info', async () => { - // Arrange - const data = { - id: 'id1', - createDateTime: '2000-01-01T00:00:00.000Z', - appVersion: '2.0.0', - osPlatform: 'win32', - buildType: 'ELECTRON' - } - const responsePayload = { status: 200, data } - - apiService.get = jest.fn().mockResolvedValue(responsePayload) - // Act await store.dispatch(fetchServerInfo(jest.fn())) // Assert const expectedActions = [ getServerInfo(), - getServerInfoSuccess(data), + getServerInfoSuccess(APP_INFO_DATA_MOCK), ] expect(mockedStore.getActions()).toEqual(expectedActions) diff --git a/redisinsight/ui/src/styles/components/_table.scss b/redisinsight/ui/src/styles/components/_table.scss index 6aea2c6e01..b5173ee337 100644 --- a/redisinsight/ui/src/styles/components/_table.scss +++ b/redisinsight/ui/src/styles/components/_table.scss @@ -51,15 +51,44 @@ table { } .inMemoryTableDefault { - > div:first-child { - @include euiScrollBar; - overflow-x: auto; + &:not(.stickyHeader) { + > div:first-child { + @include euiScrollBar; + overflow-x: auto; + } } &.euiBasicTable-loading table { overflow: hidden; } + &.noHeaderBorders { + .euiTableRow { + &:not(:first-child) { + .euiTableRowCell { + border-top: 0 !important; + } + } + } + .euiTableRowCell { + &:not(:last-child) { + border-right: 0 !important; + } + } + + .euiTableHeaderCell { + border: 0 !important; + } + } + + &.stickyHeader { + .euiTableHeaderCell { + position: sticky; + top: 0; + z-index: 1; + } + } + table { overflow: initial; table-layout: auto; @@ -82,7 +111,7 @@ table { } .euiTableCellContent span { - color: var(--textColorShade) !important; + color: var(--textColorShade); padding-top: 1px; max-width: 100%; overflow: hidden; @@ -91,12 +120,12 @@ table { } .euiTableHeaderCell { - letter-spacing: 0px; + letter-spacing: 0; border: 1px solid var(--tableLightestBorderColor); .euiTableCellContent__text { color: var(--htmlColor) !important; - font: normal normal 500 14px/17px Graphik, sans-serif !important; + font: normal normal 500 14px/17px Graphik, sans-serif; letter-spacing: -0.14px; } } diff --git a/redisinsight/ui/src/styles/components/_tabs.scss b/redisinsight/ui/src/styles/components/_tabs.scss index 041b5765b2..b03e7f503e 100644 --- a/redisinsight/ui/src/styles/components/_tabs.scss +++ b/redisinsight/ui/src/styles/components/_tabs.scss @@ -45,3 +45,32 @@ width: 2px; } } + +.tabs-active-borders { + .euiTab { + border-radius: 0; + padding: 8px 12px !important; + border-bottom: 1px solid var(--separatorColor); + color: var(--euiTextSubduedColor) !important; + + &.euiTab-isSelected { + color: var(--euiColorPrimary) !important; + background-color: inherit !important; + border-bottom: 2px solid var(--euiColorPrimary); + } + + .euiTab__content { + font-size: 13px !important; + line-height: 18px !important; + font-weight: 500 !important; + } + } + + .euiTab + .euiTab { + margin-left: 0 !important; + + &::after { + display: none !important; + } + } +} diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index ef84933a8c..c2d607ae55 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -101,6 +101,7 @@ --iconsDefaultHoverColor: #{$iconsDefaultHoverColor}; --separatorColor: #{$separatorColor}; + --separatorColorLight: #{$separatorColorLight}; --separatorNavigationColor: #{$separatorNavigationColor}; --separatorDropdownColor: #{$separatorDropdownColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index cdb3199254..8e193cb5c4 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -60,6 +60,7 @@ $controlsLabelColor: #b5b6c0; $iconsDefaultColor: #b5b6c0; $iconsDefaultHoverColor: #dfe5ef; $separatorColor: #3d3d3d; +$separatorColorLight: #555555; $separatorNavigationColor: #465282; $separatorDropdownColor: #8b90a3; @@ -126,7 +127,7 @@ $rsSubmitBtn: #1ae26e; // Workbench $wbRunResultsBg: #000; $wbHoverIconColor: #ffffff; -$wbActiveIconColor: #8BA2FF; +$wbActiveIconColor: #8ba2ff; // PubSub $pubSubClientsBadge: #008000; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index b5079e11ef..34742493f6 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -103,6 +103,7 @@ --iconsDefaultHoverColor: #{$iconsDefaultHoverColor}; --separatorColor: #{$separatorColor}; + --separatorColorLight: #{$separatorColorLight}; --separatorNavigationColor: #{$separatorNavigationColor}; --separatorDropdownColor: #{$separatorDropdownColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index 5c4f469680..53996430d2 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -57,6 +57,7 @@ $controlsLabelHoverColor: #173369; $iconsDefaultColor: #728baf; $iconsDefaultHoverColor: #173369; $separatorColor: #cdd7e6; +$separatorColorLight: #B2B9D1; $separatorNavigationColor: #465282; $separatorDropdownColor: #8b90a3; diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index 70e0582aa7..7af5a7744e 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -5,5 +5,6 @@ export enum TelemetryPageView { BROWSER_PAGE = 'Browser', WORKBENCH_PAGE = 'Workbench', SLOWLOG_PAGE = 'Slow Log', + CLUSTER_DETAILS_PAGE = 'Overview', PUBSUB_PAGE = 'Pub/Sub' } diff --git a/redisinsight/ui/src/utils/colors.ts b/redisinsight/ui/src/utils/colors.ts new file mode 100644 index 0000000000..d7886092fd --- /dev/null +++ b/redisinsight/ui/src/utils/colors.ts @@ -0,0 +1,48 @@ +export type RGBColor = [number, number, number] + +export interface ColorScheme { + cHueStart: number + cHueRange: number + cSaturation: number + cLightness: number +} + +const HSLToRGB = (h: number, sI: number, lI: number): RGBColor => { + const s = sI / 100 + const l = lI / 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + + return [255 * f(0), 255 * f(8), 255 * f(4)] +} + +const PBC = (r: number, g: number, b: number): number => + Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) / 255 + +const correctBrightness = (rgb: RGBColor, cLightness: number) => 1 / ((PBC(...rgb) * 100) / cLightness) + +const applyBrightnessToRGB = (rgb: RGBColor, cLightness: number): RGBColor => { + const [r, g, b] = rgb + return [ + Math.round(r * correctBrightness([r, g, b], cLightness)), + Math.round(g * correctBrightness([r, g, b], cLightness)), + Math.round(b * correctBrightness([r, g, b], cLightness)) + ] as RGBColor +} + +const getRGBColorByScheme = (index: number, shift: number, colorScheme: ColorScheme): RGBColor => { + const nc = index * shift + colorScheme.cHueStart + const rgb: RGBColor = HSLToRGB(nc, colorScheme.cSaturation, colorScheme.cLightness) + return applyBrightnessToRGB(rgb, colorScheme.cLightness) +} + +const rgb = (rgb: RGBColor) => `rgb(${rgb.join(', ')})` + +export { + HSLToRGB, + correctBrightness, + applyBrightnessToRGB, + getRGBColorByScheme, + rgb +} diff --git a/redisinsight/ui/src/utils/getLetterByIndex.ts b/redisinsight/ui/src/utils/getLetterByIndex.ts new file mode 100644 index 0000000000..f2c570010a --- /dev/null +++ b/redisinsight/ui/src/utils/getLetterByIndex.ts @@ -0,0 +1,8 @@ +const getLetterByIndex = (index: number): string => { + const mod = index % 26 + const pow = index / 26 | 0 + const out = String.fromCharCode(65 + mod) + return pow ? getLetterByIndex(pow - 1) + out : out +} + +export default getLetterByIndex diff --git a/redisinsight/ui/src/utils/index.ts b/redisinsight/ui/src/utils/index.ts index 6d0dab29da..d6372ac753 100644 --- a/redisinsight/ui/src/utils/index.ts +++ b/redisinsight/ui/src/utils/index.ts @@ -7,6 +7,7 @@ import replaceSpaces from './replaceSpaces' import setFavicon from './setFavicon' import setTitle from './setPageTitle' import formatToText from './cliTextFormatter' +import getLetterByIndex from './getLetterByIndex' export * from './common' export * from './validations' @@ -53,4 +54,5 @@ export { setFavicon, setTitle, formatToText, + getLetterByIndex } diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 10811075da..3ede2a6426 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -37,7 +37,9 @@ import { initialState as initialStateWBResults } from 'uiSrc/slices/workbench/wb import { initialState as initialStateWBEGuides } from 'uiSrc/slices/workbench/wb-guides' import { initialState as initialStateWBETutorials } from 'uiSrc/slices/workbench/wb-tutorials' import { initialState as initialStateCreateRedisButtons } from 'uiSrc/slices/content/create-redis-buttons' -import { initialState as initialStateSlowLog } from 'uiSrc/slices/slowlog/slowlog' +import { initialState as initialStateSlowLog } from 'uiSrc/slices/analytics/slowlog' +import { initialState as initialClusterDetails } from 'uiSrc/slices/analytics/clusterDetails' +import { initialState as initialStateAnalyticsSettings } from 'uiSrc/slices/analytics/settings' import { initialState as initialStatePubSub } from 'uiSrc/slices/pubsub/pubsub' import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' import { apiService } from 'uiSrc/services' @@ -94,7 +96,11 @@ const initialStateDefault: RootState = { content: { createRedisButtons: cloneDeep(initialStateCreateRedisButtons) }, - slowlog: cloneDeep(initialStateSlowLog), + analytics: { + settings: cloneDeep(initialStateAnalyticsSettings), + slowlog: cloneDeep(initialStateSlowLog), + clusterDetails: cloneDeep(initialClusterDetails), + }, pubsub: cloneDeep(initialStatePubSub), } @@ -193,7 +199,7 @@ window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock export const getMswResourceURL = (path: string = '') => RESOURCES_BASE_URL.concat(path) export const getMswURL = (path: string = '') => - apiService.defaults.baseURL?.concat(path) ?? '' + apiService.defaults.baseURL?.concat(path.startsWith('/') ? path.slice(1) : path) ?? '' // re-export everything export * from '@testing-library/react' diff --git a/redisinsight/ui/src/utils/tests/colors.spec.ts b/redisinsight/ui/src/utils/tests/colors.spec.ts new file mode 100644 index 0000000000..42618f637a --- /dev/null +++ b/redisinsight/ui/src/utils/tests/colors.spec.ts @@ -0,0 +1,37 @@ +import { ColorScheme, getRGBColorByScheme, rgb } from 'uiSrc/utils/colors' + +const colorScheme: ColorScheme = { + cHueStart: 180, + cHueRange: 140, + cSaturation: 55, + cLightness: 45 +} + +const RGBColorsTests: any[] = [ + // colors for length 3 + [0, 0, [39, 135, 135]], + [1, 140 / 3, [66, 101, 226]], + [2, 140 / 3, [143, 60, 208]], + + // other colors + [1, 140 / 3, [66, 101, 226]], + [2, 140 / 4, [101, 72, 248]], + [3, 140 / 5, [129, 65, 224]], + [4, 140 / 6, [143, 60, 208]], + [5, 140 / 7, [151, 57, 197]], +] + +describe('getRGBColorByScheme', () => { + it.each(RGBColorsTests)('for input: %s (index), %s (shift), should be output: %s', + (index, shift, expected) => { + const result = getRGBColorByScheme(index, shift, colorScheme) + expect(result).toEqual(expected) + }) +}) + +describe('rgb', () => { + it('should return proper rgb string color', () => { + expect(rgb([0, 0, 0])).toEqual('rgb(0, 0, 0)') + expect(rgb([100, 30, 10])).toEqual('rgb(100, 30, 10)') + }) +}) diff --git a/redisinsight/ui/src/utils/tests/getLetterByIndex.spec.ts b/redisinsight/ui/src/utils/tests/getLetterByIndex.spec.ts new file mode 100644 index 0000000000..e882ab28ab --- /dev/null +++ b/redisinsight/ui/src/utils/tests/getLetterByIndex.spec.ts @@ -0,0 +1,18 @@ +import { getLetterByIndex } from 'uiSrc/utils' + +const getLetterByIndexTests: any[] = [ + [0, 'A'], + [5, 'F'], + [25, 'Z'], + [26, 'AA'], + [52, 'BA'], + [522, 'TC'], + [1024, 'AMK'], +] + +describe('getLetterByIndex', () => { + it.each(getLetterByIndexTests)('for input: %s (index), should be output: %s', + (index, expected) => { + expect(getLetterByIndex(index)).toBe(expected) + }) +}) diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index c72906b7a8..a28f1d523e 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -1,7 +1,7 @@ import { t } from 'testcafe'; import * as request from 'supertest'; import { asyncFilter, doAsyncStuff } from '../async-helper'; -import { AddNewDatabaseParameters, OSSClusterParameters, databaseParameters, SentinelParameters } from '../../pageObjects/add-redis-database-page'; +import { AddNewDatabaseParameters, OSSClusterParameters, databaseParameters, SentinelParameters, ClusterNodes } from '../../pageObjects/add-redis-database-page'; import { Common } from '../common'; const common = new Common(); @@ -195,3 +195,18 @@ export async function deleteStandaloneDatabasesApi(databasesParameters: AddNewDa }); } } + +/** + * Get OSS Cluster nodes + * @param databaseParameters The database parameters + */ +export async function getClusterNodesApi(databaseParameters: OSSClusterParameters): Promise { + const databaseId = await getDatabaseByName(databaseParameters.ossClusterDatabaseName); + const response = await request(endpoint) + .get(`/instance/${databaseId}/cluster-details`) + .set('Accept', 'application/json') + .expect(200); + let nodes = await response.body.nodes; + let nodeNames = await nodes.map((node: ClusterNodes) => (node.host + ':' + node.port)); + return nodeNames; +} diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index 1b86c13236..e0792aa206 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -224,3 +224,13 @@ export type databaseParameters = { connectionType?: string, lastConnection?: string }; + +/** + * Nodes in OSS Cluster parameters + * @param host The host of the node + * @param port The port of the node + */ + export type ClusterNodes = { + host: string, + port: string +}; diff --git a/tests/e2e/pageObjects/index.ts b/tests/e2e/pageObjects/index.ts index e18a654206..45e05c7360 100644 --- a/tests/e2e/pageObjects/index.ts +++ b/tests/e2e/pageObjects/index.ts @@ -11,6 +11,7 @@ import { DatabaseOverviewPage } from './database-overview-page'; import { HelpCenterPage } from './help-center-page'; import { ShortcutsPage } from './shortcuts-page'; import { MonitorPage } from './monitor-page'; +import { OverviewPage } from './overview-page'; import { PubSubPage } from './pub-sub-page'; import { SlowLogPage } from './slow-log-page'; import { NotificationPage } from './notification-page'; @@ -29,6 +30,7 @@ export { HelpCenterPage, ShortcutsPage, MonitorPage, + OverviewPage, PubSubPage, SlowLogPage, NotificationPage diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 933441a0e0..9b9601d08b 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -10,6 +10,7 @@ export class MyRedisDatabasePage { //BUTTONS settingsButton = Selector('[data-testid=settings-page-btn]'); workbenchButton = Selector('[data-testid=workbench-page-btn]'); + analysisPageButton = Selector('[data-testid=analytics-page-btn]'); helpCenterButton = Selector('[data-testid=help-menu-button]'); githubButton = Selector('[data-testid=github-repo-icon]'); browserButton = Selector('[data-testid=browser-page-btn]'); diff --git a/tests/e2e/pageObjects/overview-page.ts b/tests/e2e/pageObjects/overview-page.ts new file mode 100644 index 0000000000..25e1a4d2d6 --- /dev/null +++ b/tests/e2e/pageObjects/overview-page.ts @@ -0,0 +1,66 @@ +import { Selector } from 'testcafe'; + +export class OverviewPage { + //CSS Selectors + cssTableRow = 'tr[class=euiTableRow]'; + //------------------------------------------------------------------------------------------- + //DECLARATION OF SELECTORS + //*Declare all elements/components of the relevant page. + //*Target any element/component via data-id, if possible! + //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). + //------------------------------------------------------------------------------------------- + //BUTTONS + overviewTab = Selector('[data-testid=analytics-tab-ClusterDetails]'); + // COMPONENTS + clusterDetailsUptime = Selector('[data-testid=cluster-details-uptime]'); + //TABLE COMPONENTS + tableHeaderCell = Selector('[data-test-subj^=tableHeaderCell]'); + primaryNodesTable = Selector('[data-testid=primary-nodes-table]'); + tableRow = Selector('tr[class=euiTableRow]'); + connectedClientsValue = Selector('[data-testid^=connectedClients-value]'); + totalKeysValue = Selector('[data-testid^=totalKeys-value]'); + networkInputValue = Selector('[data-testid^=networkInKbps-value]'); + networkOutputValue = Selector('[data-testid^=networkOutKbps-value]'); + + /** + * Get Primary nodes count in table + */ + async getPrimaryNodesCount(): Promise { + return await this.primaryNodesTable.find(this.cssTableRow).count; + } + + /** + * Get total value from all rows in column + * @param column The column name + */ + async getTotalValueByColumnName(column: string): Promise { + let totalNumber = 0; + let columnInSelector = ''; + switch (column) { + case 'Commands/s': + columnInSelector = 'opsPerSecond'; + break; + case 'Clients': + columnInSelector = 'connectedClients'; + break; + case 'Total Keys': + columnInSelector = 'totalKeys'; + break; + case 'Network Input': + columnInSelector = 'networkInKbps'; + break; + case 'Network Output': + columnInSelector = 'networkOutKbps'; + break; + case 'Total Memory': + columnInSelector = 'usedMemory'; + break; + default: columnInSelector = ''; + } + const rowSelector = Selector(`[data-testid^=${columnInSelector}-value]`); + for (let i = 0; i < await rowSelector.count; i++) { + totalNumber += Number(await rowSelector.nth(i).textContent); + } + return totalNumber; + } +} diff --git a/tests/e2e/pageObjects/slow-log-page.ts b/tests/e2e/pageObjects/slow-log-page.ts index 70dd501bbb..68265537e9 100644 --- a/tests/e2e/pageObjects/slow-log-page.ts +++ b/tests/e2e/pageObjects/slow-log-page.ts @@ -10,7 +10,6 @@ export class SlowLogPage { //CSS Selectors cssSelectorDurationValue = '[data-testid=duration-value]'; //BUTTONS - slowLogPageButton = Selector('[data-testid=slowlog-page-btn]'); slowLogSortByTimestamp = Selector('[data-testid=header-sorting-button]'); slowLogNumberOfCommandsDropdown = Selector('[data-testid=count-select]'); slowLogConfigureButton = Selector('[data-testid=configure-btn]'); diff --git a/tests/e2e/tests/critical-path/overview/overview.e2e.ts b/tests/e2e/tests/critical-path/overview/overview.e2e.ts new file mode 100644 index 0000000000..0458e52336 --- /dev/null +++ b/tests/e2e/tests/critical-path/overview/overview.e2e.ts @@ -0,0 +1,86 @@ +import { Selector } from 'testcafe'; +import { MyRedisDatabasePage, CliPage, OverviewPage, WorkbenchPage } from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +import { acceptLicenseTermsAndAddOSSClusterDatabase } from '../../../helpers/database'; +import { commonUrl, ossClusterConfig } from '../../../helpers/conf'; +import { deleteOSSClusterDatabaseApi, getClusterNodesApi } from '../../../helpers/api/api-database'; +import { Common } from '../../../helpers/common'; + +const overviewPage = new OverviewPage(); +const myRedisDatabasePage = new MyRedisDatabasePage(); +const common = new Common(); +const cliPage = new CliPage(); +const workbenchPage = new WorkbenchPage(); + +const headerColumns = { + 'Type': 'OSS Cluster', + 'Version': '7.0.0', + 'User': 'Default' +}; +const keyName = common.generateWord(10); +const commandToAddKey = `set ${keyName} test`; + +fixture `Overview` + .meta({ type: 'critical_path', rte: rte.ossCluster }) + .page(commonUrl) + .beforeEach(async t => { + await acceptLicenseTermsAndAddOSSClusterDatabase(ossClusterConfig, ossClusterConfig.ossClusterDatabaseName); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + }) + .afterEach(async() => { + await deleteOSSClusterDatabaseApi(ossClusterConfig); + }); +test('Overview tab header for OSS Cluster', async t => { + const uptime = /[1-9][0-9]\s|[0-9]\smin|[1-9][0-9]\smin|[0-9]\sh/; + // Verify that user see "Overview" tab by default for OSS Cluster + await t.expect(overviewPage.overviewTab.withAttribute('aria-selected', 'true').exists).ok('The Overview tab not opened'); + // Verify that user see "Overview" header with OSS Cluster info + for (const key in headerColumns) { + const columnSelector = Selector(`[data-testid=cluster-details-item-${key}]`); + await t.expect(columnSelector.textContent).contains(`${headerColumns[key]}`, `Cluster detail ${key} is incorrect`); + } + // Verify that Uptime is displayed as time in seconds or minutes from start + await t.expect(overviewPage.clusterDetailsUptime.textContent).match(uptime, 'Uptime value is not correct'); +}); +test + .after(async() => { + //Clear database and delete + await cliPage.sendCommandInCli(`DEL ${keyName}`); + await cliPage.sendCommandInCli('FT.DROPINDEX idx:schools DD'); + await deleteOSSClusterDatabaseApi(ossClusterConfig); + })('Primary node statistics table displaying', async t => { + // Remember initial table values + const initialValues: number[] = []; + const nodes = (await getClusterNodesApi(ossClusterConfig)).sort(); + const columns = ['Commands/s', 'Clients', 'Total Keys', 'Network Input', 'Network Output', 'Total Memory']; + for (const column in columns) { + initialValues.push(await overviewPage.getTotalValueByColumnName(column)); + } + const nodesNumberInHeader = parseInt((await overviewPage.tableHeaderCell.nth(0).textContent).match(/\d+/)![0]); + + // Add key from CLI + await t.click(cliPage.cliExpandButton); + await t.typeText(cliPage.cliCommandInput, commandToAddKey); + await t.pressKey('enter'); + await t.click(cliPage.cliCollapseButton); + // Verify nodes in header column equal to rows + await t.expect(await overviewPage.getPrimaryNodesCount()).eql(nodesNumberInHeader, 'Primary nodes in table are not displayed'); + // Verify that all nodes from BE response are displayed in table + for (const node of nodes) { + await t.expect(overviewPage.tableRow.nth(nodes.indexOf(node)).textContent).contains(node, `Node ${node} is not displayed in table`); + } + // Go to Workbench page + await t.click(myRedisDatabasePage.workbenchButton); + //Run Create hash index command to load network and memory + await t.click(workbenchPage.documentButtonInQuickGuides); + await t.click(workbenchPage.internalLinkWorkingWithHashes); + await t.click(workbenchPage.preselectCreateHashIndex); + await t.click(workbenchPage.submitCommandButton); + // Go to Analysis Tools page + await t.click(myRedisDatabasePage.analysisPageButton); + // Verify that values in table are dynamic + for (const column in columns) { + await t.expect(await overviewPage.getTotalValueByColumnName(column)).notEql(initialValues[columns.indexOf(column)], `${column} not dynamic`); + } + }); diff --git a/tests/e2e/tests/critical-path/slow-log/slow-log.e2e.ts b/tests/e2e/tests/critical-path/slow-log/slow-log.e2e.ts index a7df59f49f..eaf2458dd3 100644 --- a/tests/e2e/tests/critical-path/slow-log/slow-log.e2e.ts +++ b/tests/e2e/tests/critical-path/slow-log/slow-log.e2e.ts @@ -1,4 +1,4 @@ -import { SlowLogPage, MyRedisDatabasePage, BrowserPage, CliPage } from '../../../pageObjects'; +import { SlowLogPage, MyRedisDatabasePage, BrowserPage, CliPage, OverviewPage } from '../../../pageObjects'; import { rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneBigConfig } from '../../../helpers/conf'; @@ -8,6 +8,7 @@ const slowLogPage = new SlowLogPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); const cliPage = new CliPage(); +const overviewPage = new OverviewPage(); const slowerThanParameter = 1; let maxCommandLength = 50; let command = `slowlog get ${maxCommandLength}`; @@ -17,13 +18,15 @@ fixture `Slow Log` .page(commonUrl) .beforeEach(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); - await t.click(slowLogPage.slowLogPageButton); + await t.click(myRedisDatabasePage.analysisPageButton); }) .afterEach(async() => { await slowLogPage.resetToDefaultConfig(); await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); }); test('Verify that user can open new Slow Log page using new icon on left app panel', async t => { + // Verify that user see "Slow Log" page by default for non OSS Cluster + await t.expect(overviewPage.overviewTab.withAttribute('aria-selected', 'true').exists).notOk('The Overview tab is displayed for non OSS Cluster db'); // Verify that user can configure slowlog-max-len for Slow Log and see whole set of commands according to the setting await slowLogPage.changeSlowerThanParameter(slowerThanParameter); await cliPage.sendCommandInCli(command); @@ -49,7 +52,7 @@ test('Verify that user can see "No Slow Logs found" message when slowlog-max-len // Go to Browser page to scan keys and turn back await t.click(myRedisDatabasePage.browserButton); await t.click(browserPage.refreshKeysButton); - await t.click(slowLogPage.slowLogPageButton); + await t.click(myRedisDatabasePage.analysisPageButton); // Compare number of logged commands with maxLength await t.expect(slowLogPage.slowLogCommandStatistics.withText(`${maxCommandLength} entries`).exists).ok('Number of displayed commands is less than '); }); @@ -73,7 +76,7 @@ test('Verify that users can specify number of commands that they want to display // Go to Browser page to scan keys and turn back await t.click(myRedisDatabasePage.browserButton); await t.click(browserPage.refreshKeysButton); - await t.click(slowLogPage.slowLogPageButton); + await t.click(myRedisDatabasePage.analysisPageButton); for (let i = 0; i < numberOfCommandsArray.length; i++) { await slowLogPage.changeDisplayUpToParameter(numberOfCommandsArray[i]); if (i === numberOfCommandsArray.length - 1) { diff --git a/yarn.lock b/yarn.lock index d34ccfab99..63b273bc9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2141,6 +2141,216 @@ resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== +"@types/d3-array@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac" + integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ== + +"@types/d3-axis@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.1.tgz#6afc20744fa5cc0cbc3e2bd367b140a79ed3e7a8" + integrity sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c" + integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.1.tgz#54c8856c19c8e4ab36a53f73ba737de4768ad248" + integrity sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw== + +"@types/d3-color@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4" + integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA== + +"@types/d3-contour@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.1.tgz#9ff4e2fd2a3910de9c5097270a7da8a6ef240017" + integrity sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41" + integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ== + +"@types/d3-dispatch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz#a1b18ae5fa055a6734cb3bd3cbc6260ef19676e3" + integrity sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw== + +"@types/d3-drag@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.1.tgz#fb1e3d5cceeee4d913caa59dedf55c94cb66e80f" + integrity sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.0.tgz#f3c61fb117bd493ec0e814856feb804a14cfc311" + integrity sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A== + +"@types/d3-ease@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0" + integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA== + +"@types/d3-fetch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.1.tgz#f9fa88b81aa2eea5814f11aec82ecfddbd0b8fe0" + integrity sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.3.tgz#76cb20d04ae798afede1ea6e41750763ff5a9c82" + integrity sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA== + +"@types/d3-format@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d" + integrity sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg== + +"@types/d3-geo@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.0.2.tgz#e7ec5f484c159b2c404c42d260e6d99d99f45d9a" + integrity sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz#4561bb7ace038f247e108295ef77b6a82193ac25" + integrity sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ== + +"@types/d3-interpolate@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" + integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" + integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== + +"@types/d3-polygon@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.0.tgz#5200a3fa793d7736fa104285fa19b0dbc2424b93" + integrity sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw== + +"@types/d3-quadtree@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz#433112a178eb7df123aab2ce11c67f51cafe8ff5" + integrity sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw== + +"@types/d3-random@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.1.tgz#5c8d42b36cd4c80b92e5626a252f994ca6bfc953" + integrity sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ== + +"@types/d3-scale-chromatic@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" + integrity sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw== + +"@types/d3-scale@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.2.tgz#41be241126af4630524ead9cb1008ab2f0f26e69" + integrity sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.3.tgz#57be7da68e7d9c9b29efefd8ea5a9ef1171e42ba" + integrity sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA== + +"@types/d3-shape@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505" + integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946" + integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw== + +"@types/d3-time@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" + integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== + +"@types/d3-timer@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce" + integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g== + +"@types/d3-transition@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.2.tgz#393dc3e3d55009a43cc6f252e73fccab6d78a8a4" + integrity sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826" + integrity sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.0.tgz#fc5cac5b1756fc592a3cf1f3dc881bf08225f515" + integrity sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/date-fns@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@types/date-fns/-/date-fns-2.6.0.tgz#b062ca46562002909be0c63a6467ed173136acc1" @@ -2219,6 +2429,11 @@ dependencies: "@types/node" "*" +"@types/geojson@*": + version "7946.0.10" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" + integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== + "@types/glob@^7.1.1": version "7.1.4" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672" @@ -4919,6 +5134,11 @@ commander@4.1.1, commander@^4.1.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +commander@7, commander@^7.0.0, commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -4934,11 +5154,6 @@ commander@^6.1.0, commander@^6.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@^7.0.0, commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -5492,6 +5707,250 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8" integrity sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ== +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" + +d3-axis@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" + integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== + +d3-brush@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" + integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "3" + d3-transition "3" + +d3-chord@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966" + integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g== + dependencies: + d3-path "1 - 3" + +"d3-color@1 - 3", d3-color@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-contour@4: + version "4.0.0" + resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.0.tgz#5a1337c6da0d528479acdb5db54bc81a0ff2ec6b" + integrity sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw== + dependencies: + d3-array "^3.2.0" + +d3-delaunay@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.2.tgz#7fd3717ad0eade2fc9939f4260acfb503f984e92" + integrity sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ== + dependencies: + delaunator "5" + +"d3-dispatch@1 - 3", d3-dispatch@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-dsv@1 - 3", d3-dsv@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73" + integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q== + dependencies: + commander "7" + iconv-lite "0.6" + rw "1" + +"d3-ease@1 - 3", d3-ease@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +d3-fetch@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22" + integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw== + dependencies: + d3-dsv "1 - 3" + +d3-force@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" + integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg== + dependencies: + d3-dispatch "1 - 3" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + +"d3-format@1 - 3", d3-format@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + +d3-geo@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.0.1.tgz#4f92362fd8685d93e3b1fae0fd97dc8980b1ed7e" + integrity sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA== + dependencies: + d3-array "2.5.0 - 3" + +d3-hierarchy@3: + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" + integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== + +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +"d3-path@1 - 3", d3-path@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" + integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== + +d3-polygon@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398" + integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg== + +"d3-quadtree@1 - 3", d3-quadtree@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" + integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== + +d3-random@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" + integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== + +d3-scale-chromatic@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" + integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g== + dependencies: + d3-color "1 - 3" + d3-interpolate "1 - 3" + +d3-scale@4: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +"d3-selection@2 - 3", d3-selection@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +d3-shape@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" + integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== + dependencies: + d3-path "1 - 3" + +"d3-time-format@2 - 4", d3-time-format@4: + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" + integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== + dependencies: + d3-array "2 - 3" + +"d3-timer@1 - 3", d3-timer@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3", d3-transition@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + +d3@^7.6.1: + version "7.6.1" + resolved "https://registry.yarnpkg.com/d3/-/d3-7.6.1.tgz#b21af9563485ed472802f8c611cc43be6c37c40c" + integrity sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw== + dependencies: + d3-array "3" + d3-axis "3" + d3-brush "3" + d3-chord "3" + d3-color "3" + d3-contour "4" + d3-delaunay "6" + d3-dispatch "3" + d3-drag "3" + d3-dsv "3" + d3-ease "3" + d3-fetch "3" + d3-force "3" + d3-format "3" + d3-geo "3" + d3-hierarchy "3" + d3-interpolate "3" + d3-path "3" + d3-polygon "3" + d3-quadtree "3" + d3-random "3" + d3-scale "4" + d3-scale-chromatic "3" + d3-selection "3" + d3-shape "3" + d3-time "3" + d3-time-format "4" + d3-timer "3" + d3-transition "3" + d3-zoom "3" + damerau-levenshtein@^1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.7.tgz#64368003512a1a6992593741a09a9d31a836f55d" @@ -5695,6 +6154,13 @@ del@^4.1.1: pify "^4.0.1" rimraf "^2.6.3" +delaunator@5: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b" + integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw== + dependencies: + robust-predicates "^3.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -8369,7 +8835,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.2: +iconv-lite@0.6, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -8565,6 +9031,11 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^1.0.0, interpret@^1.2.0: version "1.4.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" @@ -13662,6 +14133,11 @@ roarr@^2.15.3: semver-compare "^1.0.0" sprintf-js "^1.1.2" +robust-predicates@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" + integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -13674,6 +14150,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rw@1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" + integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== + rxjs@6.6.3, rxjs@^6.5.2, rxjs@^6.6.0, rxjs@^6.6.3: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552"