diff --git a/.env b/.env index a89c5dce..a1ba2a50 100644 --- a/.env +++ b/.env @@ -15,7 +15,7 @@ RI_STDOUT_LOGGER=false RI_AUTO_BOOTSTRAP=false RI_MIGRATE_OLD_FOLDERS=false RI_BUILD_TYPE='VS_CODE' -RI_ENCRYPTION_KEYTAR=false RI_ANALYTICS_START_EVENTS=true RI_AGREEMENTS_PATH='../../webviews/resources/agreements-spec.json' +RI_ENCRYPTION_KEYTAR_SERVICE="redis-for-vscode" # RI_SEGMENT_WRITE_KEY='SEGMENT_WRITE_KEY' diff --git a/.github/workflows/pipeline-build-linux.yml b/.github/workflows/pipeline-build-linux.yml index 8339b0c9..f9519361 100644 --- a/.github/workflows/pipeline-build-linux.yml +++ b/.github/workflows/pipeline-build-linux.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/download-backend - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} - name: Build linux package (production) if: inputs.environment == 'production' @@ -45,7 +45,7 @@ jobs: - name: Build linux package (staging) if: inputs.environment == 'staging' run: | - sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} yarn package:stage --target linux-x64 --out ${packagePath} - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pipeline-build-macos.yml b/.github/workflows/pipeline-build-macos.yml index d2296cab..c12a9b3e 100644 --- a/.github/workflows/pipeline-build-macos.yml +++ b/.github/workflows/pipeline-build-macos.yml @@ -28,7 +28,7 @@ jobs: uses: ./.github/actions/install-all-build-libs - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} - name: Download backend x64 uses: ./.github/actions/download-backend @@ -38,7 +38,7 @@ jobs: - name: Build macos x64 package (staging) if: inputs.environment != 'production' run: | - sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} yarn package:stage --target darwin-x64 --out ${packagePath}-x64.vsix @@ -55,7 +55,7 @@ jobs: - name: Build macos arm64 package (staging) if: inputs.environment != 'production' run: | - sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} yarn package:stage --target darwin-arm64 --out ${packagePath}-arm64.vsix diff --git a/.github/workflows/pipeline-build-windows.yml b/.github/workflows/pipeline-build-windows.yml index c8b40802..dc3600e1 100644 --- a/.github/workflows/pipeline-build-windows.yml +++ b/.github/workflows/pipeline-build-windows.yml @@ -25,7 +25,7 @@ jobs: uses: ./.github/actions/download-backend - name: Set RI_SEGMENT_WRITE_KEY to .env file - run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} - name: Build windows package (production) if: inputs.environment == 'production' @@ -35,7 +35,7 @@ jobs: - name: Build windows package (staging) if: inputs.environment == 'staging' run: | - sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} yarn package:stage --target win32-x64 --out ${{ env.packagePath }} - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/tests-e2e-linux.yml b/.github/workflows/tests-e2e-linux.yml index df52448d..a9850a1d 100644 --- a/.github/workflows/tests-e2e-linux.yml +++ b/.github/workflows/tests-e2e-linux.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20.15' + node-version: '20.18.0' - name: Download linux artifact uses: actions/download-artifact@v4 diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index 207a4008..c13eabe2 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -3,7 +3,15 @@ on: workflow_call: env: + SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }} SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }} + AWS_BUCKET_NAME_TEST: ${{ vars.AWS_BUCKET_NAME_TEST }} + AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} + AWS_DISTRIBUTION_ID: ${{ secrets.AWS_DISTRIBUTION_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + REPORT_NAME: "report-vscode-fe" + S3_PATH: "report-vscode-fe" jobs: unit-tests: @@ -18,21 +26,30 @@ jobs: - name: Unit tests UI run: yarn test:cov - - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + - name: Get current date + id: date if: always() - with: - check_name: 'FE Unit tests summary' - comment_mode: 'failures' - files: reports/junit.xml + uses: RedisInsight/RedisInsight/.github/actions/get-current-date@873a0ebf55c85d3127bb4efb4d0636d9ab838226 - - name: Generate test results - uses: dorny/test-reporter@v1 + + - name: Deploy 🚀 + if: always() + run: | + + GZIP_FILE=html.meta.json.gz + S3_SUB_PATH="test-reports/${{ steps.date.outputs.date }}/${{ github.run_id }}/${{ env.REPORT_NAME }}" + + aws s3 cp report/ s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH} --recursive --exclude "*.gz" + + # s3 modified "gzip" content-type + # https://github.com/aws/aws-cli/issues/1131 + aws s3 cp report/${GZIP_FILE} s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH}/${GZIP_FILE} --content-type "application/x-gzip" --metadata-directive REPLACE + + echo "S3_SUB_PATH=${S3_SUB_PATH}" >> $GITHUB_ENV + + + - name: Add link to report in the workflow summary if: always() - with: - name: 'Test results: FE unit tests' - path: reports/junit.xml - reporter: jest-junit - list-tests: 'failed' - list-suites: 'failed' - fail-on-error: 'false' + run: | + link="${{ vars.DEFAULT_TEST_REPORTS_URL }}/${S3_SUB_PATH}/index.html" + echo "[${link}](${link})" >> $GITHUB_STEP_SUMMARY diff --git a/package.json b/package.json index d829405c..e2ea083c 100644 --- a/package.json +++ b/package.json @@ -236,6 +236,7 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.4.5", + "upath": "^2.0.1", "uuid": "^9.0.1", "vite": "^5.2.10", "vite-plugin-react-click-to-component": "^3.0.0", diff --git a/scripts/downloadBackend.ts b/scripts/downloadBackend.ts index 0d0caaaf..7550b5ee 100755 --- a/scripts/downloadBackend.ts +++ b/scripts/downloadBackend.ts @@ -3,8 +3,10 @@ import * as fs from 'fs' import * as path from 'path' import * as cp from 'child_process' import * as dotenv from 'dotenv' +import * as upath from 'upath' import { parse as parseUrl } from 'url' + dotenv.config({ path: [ path.join(__dirname, '..', '.env'), @@ -80,13 +82,28 @@ async function downloadRedisBackendArchive( }) } +function getNormalizedString(string: string) { + return string?.startsWith('D:') + ? upath.normalize(string).replace('D:', '/d') + : string +} + function unzipRedisServer(redisInsideArchivePath: string, extractDir: string) { // tar does not create extractDir by default if (!fs.existsSync(extractDir)) { fs.mkdirSync(extractDir) } - cp.spawnSync('tar', ['-xf', redisInsideArchivePath, '-C', extractDir, '--strip-components', '1', 'api']) + cp.spawnSync('tar', [ + '-xf', + getNormalizedString(redisInsideArchivePath), + '-C', + getNormalizedString(extractDir), + '--strip-components', + '1', + 'api', + ]) + // remove tutorials fs.rmSync(tutorialsPath, { recursive: true, force: true }); diff --git a/src/resources/agreements-spec.json b/src/resources/agreements-spec.json index 71d31fdc..890abb4f 100644 --- a/src/resources/agreements-spec.json +++ b/src/resources/agreements-spec.json @@ -1,5 +1,5 @@ { - "version": "1.0.1", + "version": "1.0.2", "agreements": { "analytics": { "defaultValue": false, @@ -35,6 +35,37 @@ "title": "Server Side Public License", "label": "I have read and understood the Terms", "requiredText": "Accept the Server Side Public License" + }, + "encryption": { + "conditional": true, + "checker": "KEYTAR", + "defaultOption": "false", + "options": { + "true": { + "defaultValue": true, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": false, + "category": "privacy", + "since": "1.0.2", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "Select to encrypt sensitive information using system keychain. Otherwise, this information is stored locally in plain text, which may incur security risk." + }, + "false": { + "defaultValue": false, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": true, + "category": "privacy", + "since": "1.0.2", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "Install or enable the system keychain to encrypt and securely store your sensitive information added before using the application. Otherwise, this information will be stored locally in plain text and may lead to security risks." + } + } } } } diff --git a/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx b/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx new file mode 100644 index 00000000..62870f3c --- /dev/null +++ b/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, screen, render, act } from 'testSrc/helpers' +import { AutoRefresh, Props } from './AutoRefresh' +import { DEFAULT_REFRESH_RATE } from './utils' + +const mockedProps = mock() + +const INLINE_ITEM_EDITOR = 'inline-item-editor' + +describe('AutoRefresh', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('prop "displayText = true" should show Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).toBeInTheDocument() + }) + + it('prop "displayText = false" should hide Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).not.toBeInTheDocument() + }) + + it('should call onRefresh', () => { + const onRefresh = vi.fn() + render() + + fireEvent.click(screen.getByTestId('refresh-btn')) + expect(onRefresh).toBeCalled() + }) + + it('refresh text should contain "Last refresh" time with disabled auto-refresh', async () => { + render() + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Last refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent('now') + }) + + it('refresh text should contain "Auto-refresh" time with enabled auto-refresh', async () => { + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Auto refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent(DEFAULT_REFRESH_RATE) + }) + + it('should locate refresh message label when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-message-label')).toBeInTheDocument() + }) + + it('should locate refresh message when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-message')).toBeInTheDocument() + }) + + it('should locate refresh button when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-btn')).toBeInTheDocument() + }) + + it('should locate auto-refresh config button when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-auto-refresh-config-btn')).toBeInTheDocument() + }) + + it('should locate auto-refresh switch when testid is provided', () => { + render() + + fireEvent.click(screen.getByTestId('testid-auto-refresh-config-btn')) + expect(screen.getByTestId('testid-auto-refresh-switch')).toBeInTheDocument() + }) + + describe('AutoRefresh Config', () => { + it('Auto refresh config should render', () => { + const { queryByTestId } = render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + expect(queryByTestId('auto-refresh-switch')).toBeInTheDocument() + }) + + it('should call onRefresh after enable auto-refresh and set 1 sec', async () => { + const onRefresh = vi.fn() + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR)) + + fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } }) + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1') + + screen.getByTestId(/apply-btn/).click() + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(1) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(2) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(3) + }) + }) + + it('should NOT call onRefresh with disabled state', async () => { + const onRefresh = vi.fn() + const { rerender } = render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR)) + fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } }) + + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1') + + screen.getByTestId(/apply-btn/).click() + + await act(() => { + rerender() + }) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(0) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(0) + + await act(() => { + rerender() + }) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(1) + }) +}) diff --git a/src/webviews/src/components/auto-refresh/AutoRefresh.tsx b/src/webviews/src/components/auto-refresh/AutoRefresh.tsx new file mode 100644 index 00000000..fc4ede88 --- /dev/null +++ b/src/webviews/src/components/auto-refresh/AutoRefresh.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from 'react' +import * as l10n from '@vscode/l10n' +import Popover from 'reactjs-popup' +import cx from 'classnames' +import { VscChevronDown, VscRefresh } from 'react-icons/vsc' + +import { + MIN_REFRESH_RATE, + StorageItem, +} from 'uiSrc/constants' +import { + errorValidateRefreshRateNumber, + validateRefreshRateNumber, +} from 'uiSrc/utils' +import { InlineEditor } from 'uiSrc/components' +import { localStorageService } from 'uiSrc/services' +import { Nullable } from 'uiSrc/interfaces' +import { Checkbox, RiButton, Tooltip } from 'uiSrc/ui' +import { + getTextByRefreshTime, + DEFAULT_REFRESH_RATE, + DURATION_FIRST_REFRESH_TIME, + MINUTE, + NOW, +} from './utils' + +import styles from './styles.module.scss' + +export interface Props { + postfix: string + loading: boolean + displayText?: boolean + lastRefreshTime: Nullable + testid?: string + containerClassName?: string + turnOffAutoRefresh?: boolean + onRefresh: (enableAutoRefresh: boolean) => void + onRefreshClicked?: () => void + onEnableAutoRefresh?: (enableAutoRefresh: boolean, refreshRate: string) => void + onChangeAutoRefreshRate?: (enableAutoRefresh: boolean, refreshRate: string) => void + disabled?: boolean + enableAutoRefreshDefault?: boolean +} + +const TIMEOUT_TO_UPDATE_REFRESH_TIME = 1_000 * MINUTE // once a minute + +const AutoRefresh = React.memo(({ + postfix, + loading, + displayText = true, + lastRefreshTime, + containerClassName = '', + testid = '', + turnOffAutoRefresh, + onRefresh, + onRefreshClicked, + onEnableAutoRefresh, + onChangeAutoRefreshRate, + disabled, + enableAutoRefreshDefault = false, +}: Props) => { + let intervalText: NodeJS.Timeout + let intervalRefresh: NodeJS.Timeout + + const [refreshMessage, setRefreshMessage] = useState(NOW) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [refreshRate, setRefreshRate] = useState('') + const [refreshRateMessage, setRefreshRateMessage] = useState('') + const [enableAutoRefresh, setEnableAutoRefresh] = useState(enableAutoRefreshDefault) + + const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen) + const closePopover = () => { + setEnableAutoRefresh(enableAutoRefresh) + setIsPopoverOpen(false) + } + + useEffect(() => { + const refreshRateStorage = localStorageService.get(StorageItem.autoRefreshRate + postfix) + || DEFAULT_REFRESH_RATE + + setRefreshRate(refreshRateStorage) + }, [postfix]) + + useEffect(() => { + if (turnOffAutoRefresh && enableAutoRefresh) { + setEnableAutoRefresh(false) + clearInterval(intervalRefresh) + } + }, [turnOffAutoRefresh]) + + // update refresh label text + useEffect(() => { + const delta = getLastRefreshDelta(lastRefreshTime) + updateLastRefresh() + + intervalText = setInterval(() => { + if (document.hidden) return + + updateLastRefresh() + }, delta < DURATION_FIRST_REFRESH_TIME ? DURATION_FIRST_REFRESH_TIME : TIMEOUT_TO_UPDATE_REFRESH_TIME) + return () => clearInterval(intervalText) + }, [lastRefreshTime]) + + // refresh interval + useEffect(() => { + updateLastRefresh() + + if (enableAutoRefresh && !loading && !disabled) { + intervalRefresh = setInterval(() => { + if (document.hidden) return + + handleRefresh() + }, +refreshRate * 1_000) + } else { + clearInterval(intervalRefresh) + } + + if (enableAutoRefresh) { + updateAutoRefreshText(refreshRate) + } + + return () => clearInterval(intervalRefresh) + }, [enableAutoRefresh, refreshRate, loading, disabled, lastRefreshTime]) + + const getLastRefreshDelta = (time:Nullable) => (Date.now() - (time || 0)) / 1_000 + + const getDataTestid = (suffix: string) => (testid ? `${testid}-${suffix}` : suffix) + + const updateLastRefresh = () => { + const delta = getLastRefreshDelta(lastRefreshTime) + const text = getTextByRefreshTime(delta, lastRefreshTime ?? 0) + + lastRefreshTime && setRefreshMessage(text) + } + + const updateAutoRefreshText = (refreshRate: string) => { + enableAutoRefresh && setRefreshRateMessage( + // more than 1 minute + +refreshRate > MINUTE ? `${Math.floor(+refreshRate / MINUTE)} min` : `${refreshRate} s`, + ) + } + + const handleApplyAutoRefreshRate = (initValue: string) => { + const value = +initValue >= MIN_REFRESH_RATE ? initValue : `${MIN_REFRESH_RATE}` + setRefreshRate(value) + localStorageService.set(StorageItem.autoRefreshRate + postfix, value) + onChangeAutoRefreshRate?.(enableAutoRefresh, value) + } + + const handleRefresh = () => { + onRefresh(enableAutoRefresh) + } + + const handleRefreshClick = () => { + handleRefresh() + onRefreshClicked?.() + } + + const onChangeEnableAutoRefresh = (value: boolean) => { + setEnableAutoRefresh(value) + + onEnableAutoRefresh?.(value, refreshRate) + } + + return ( +
+ + {displayText && ( + {enableAutoRefresh ? l10n.t('Auto refresh:') : l10n.t('Last refresh:')} + )} + {displayText && ( + {` ${enableAutoRefresh ? refreshRateMessage : refreshMessage}`} + )} + + + + + + + + + + + + )} + > +
+ onChangeEnableAutoRefresh(e.target.checked)} + data-testid={getDataTestid('auto-refresh-switch')} + className={styles.switchOption} + labelText={l10n.t('Auto Refresh')} + /> +
+
+
Refresh rate:
+
+ handleApplyAutoRefreshRate(value)} + /> +
+
+
+
+ ) +}) + +export { AutoRefresh } diff --git a/src/webviews/src/components/auto-refresh/styles.module.scss b/src/webviews/src/components/auto-refresh/styles.module.scss new file mode 100644 index 00000000..d34f4f3e --- /dev/null +++ b/src/webviews/src/components/auto-refresh/styles.module.scss @@ -0,0 +1,61 @@ +.container { + @apply flex justify-center items-center relative whitespace-nowrap; +} + +.btn { + transition: transform 0.3s ease; + + &.rolling svg { + color: var(--vscode-button-background) !important; + } +} + +.time { + @apply pr-1.5; + + &.disabled { + @apply opacity-50; + } +} + + +:global(.popover-auto-refresh-content) { + @apply max-w-[225px] w-[225px] #{!important}; + +} + +.switch { + @apply pb-4; +} + +.input { + @apply h-[30px] inline-block w-[90px]; + + input { + @apply h-[30px]; + } +} +.inputContainer { + @apply h-[30px]; +} + +.inputLabel { + @apply inline-block w-20; +} + +.anchorBtn { + @apply min-h-[14px] min-w-[14px] -ml-[3px]; + + svg { + @apply w-[10px] h-[10px]; + } +} + +.enable { + svg { + fill: var(--vscode-button-background) !important; + } + .time { + color: var(--vscode-button-background) !important; + } +} diff --git a/src/webviews/src/components/auto-refresh/utils.ts b/src/webviews/src/components/auto-refresh/utils.ts new file mode 100644 index 00000000..d2f8261b --- /dev/null +++ b/src/webviews/src/components/auto-refresh/utils.ts @@ -0,0 +1,23 @@ +import * as l10n from '@vscode/l10n' +import { truncateNumberToFirstUnit } from 'uiSrc/utils' + +export const NOW = l10n.t('now') +export const MINUTE = 60 +export const DURATION_FIRST_REFRESH_TIME = 5 +export const DEFAULT_REFRESH_RATE = '5.0' + +export const getTextByRefreshTime = (delta: number, lastRefreshTime: number) => { + let text = '' + + if (delta > MINUTE) { + text = truncateNumberToFirstUnit((Date.now() - (lastRefreshTime || 0)) / 1_000) + } + if (delta < MINUTE) { + text = '< 1 min' + } + if (delta < DURATION_FIRST_REFRESH_TIME) { + text = NOW + } + + return text +} diff --git a/src/webviews/src/components/index.ts b/src/webviews/src/components/index.ts index f0a11a3a..0a7e0e6a 100644 --- a/src/webviews/src/components/index.ts +++ b/src/webviews/src/components/index.ts @@ -10,6 +10,7 @@ export { FieldMessage } from './field-message/FieldMessage' export { NoDatabases } from './no-databases/NoDatabases' export { MonacoLanguages } from './monaco-languages/MonacoLanguages' export { UploadFile } from './upload-file/UploadFile' +export { AutoRefresh } from './auto-refresh/AutoRefresh' export * from './database-form' export * from './consents-option' export * from './consents-privacy' diff --git a/src/webviews/src/constants/core/storage.ts b/src/webviews/src/constants/core/storage.ts index 6a54081f..a2511d69 100644 --- a/src/webviews/src/constants/core/storage.ts +++ b/src/webviews/src/constants/core/storage.ts @@ -31,6 +31,7 @@ enum StorageItem { cliDatabase = 'cliDatabase', databaseId = 'databaseId', openTreeNode = 'openTreeNode', + openTreeDatabase = 'openTreeDatabase', } export { StorageItem } diff --git a/src/webviews/src/modules/eula/Eula.tsx b/src/webviews/src/modules/eula/Eula.tsx index a8a3ab21..ffd51e4b 100644 --- a/src/webviews/src/modules/eula/Eula.tsx +++ b/src/webviews/src/modules/eula/Eula.tsx @@ -155,7 +155,7 @@ export const Eula = ({ onSubmitted }: Props) => { // : TelemetryEvent.SETTINGS_NOTIFICATION_MESSAGES_DISABLED, // }) // } - updateUserConfigSettingsAction({ agreements: { ...values, encryption: false } }, onSubmitted) + updateUserConfigSettingsAction({ agreements: values }, onSubmitted) } const SubmitBtn: ReactElement = ( diff --git a/src/webviews/src/modules/key-details-header/KeyDetailsHeader.spec.tsx b/src/webviews/src/modules/key-details-header/KeyDetailsHeader.spec.tsx index 133ee659..5268b000 100644 --- a/src/webviews/src/modules/key-details-header/KeyDetailsHeader.spec.tsx +++ b/src/webviews/src/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -13,7 +13,7 @@ const mockedProps = mock() const KEY_INPUT_TEST_ID = 'edit-key-input' const TTL_INPUT_TEST_ID = 'edit-ttl-input' const KEY_COPY_TEST_ID = 'copy-name-button' -const REFRESH_TEST_ID = 'refresh-key-btn' +const REFRESH_TEST_ID = 'key-refresh-btn' const supportedKeyTypes = [ KeyTypes.Hash, KeyTypes.List, @@ -94,6 +94,6 @@ describe('KeyDetailsHeader', () => { render() - expect(screen.getByTestId(REFRESH_TEST_ID)?.lastChild).toBeDisabled() + expect(screen.getByTestId(REFRESH_TEST_ID)).toBeDisabled() }) }) diff --git a/src/webviews/src/modules/key-details-header/KeyDetailsHeader.tsx b/src/webviews/src/modules/key-details-header/KeyDetailsHeader.tsx index 7d856094..8f4e41c1 100644 --- a/src/webviews/src/modules/key-details-header/KeyDetailsHeader.tsx +++ b/src/webviews/src/modules/key-details-header/KeyDetailsHeader.tsx @@ -1,18 +1,17 @@ -import React, { ReactElement, ReactNode } from 'react' -import { isUndefined } from 'lodash' +import React, { Fragment, ReactNode } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { useShallow } from 'zustand/react/shallow' import * as l10n from '@vscode/l10n' import { AllKeyTypes, + HIDE_LAST_REFRESH, KeyTypes, } from 'uiSrc/constants' import { Maybe, RedisString } from 'uiSrc/interfaces' import { editKeyTTL, refreshKeyInfo, useDatabasesStore, useSelectedKeyStore, editKey } from 'uiSrc/store' import { TelemetryEvent, formatLongName, getGroupTypeDisplay, sendEventTelemetry } from 'uiSrc/utils' -import { PopoverDelete } from 'uiSrc/components' -import { RefreshBtn } from 'uiSrc/ui' +import { AutoRefresh, PopoverDelete } from 'uiSrc/components' import { KeyDetailsHeaderFormatter } from './components/key-details-header-formatter' import { KeyDetailsHeaderName } from './components/key-details-header-name' import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl' @@ -35,11 +34,13 @@ const KeyDetailsHeader = ({ onRemoveKey, onEditKey, keyType, - Actions, + Actions = Fragment, }: KeyDetailsHeaderProps) => { - const { data, refreshDisabled, lastRefreshTime } = useSelectedKeyStore(useShallow((state) => ({ + const { data, refreshDisabled, loading, refreshing, lastRefreshTime } = useSelectedKeyStore(useShallow((state) => ({ data: state.data, refreshDisabled: state.refreshDisabled || state.loading, + refreshing: state.refreshing, + loading: state.loading, lastRefreshTime: state.lastRefreshTime, }))) @@ -85,24 +86,29 @@ const KeyDetailsHeader = ({ }) } - const RefreshButton = () => ( - - ) + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + const event = enableAutoRefresh + ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED + sendEventTelemetry({ + event, + eventData: { + length, + databaseId, + keyType: type, + refreshRate: +refreshRate, + }, + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + handleEnableAutoRefresh(enableAutoRefresh, refreshRate) + } + } return (
- {/* {loading && ( -
- {l10n.t('loading...')} -
- )} */} - {/* {!loading && ( */} {({ width = 0 }) => (
@@ -117,12 +123,20 @@ const KeyDetailsHeader = ({
- {isUndefined(Actions) && } - {!isUndefined(Actions) && ( - - - - )} + + HIDE_LAST_REFRESH} + containerClassName={styles.actionBtn} + onRefresh={handleRefreshKey} + onEnableAutoRefresh={handleEnableAutoRefresh} + onChangeAutoRefreshRate={handleChangeAutoRefreshRate} + testid="key" + /> + {Object.values(KeyTypes).includes(keyType as KeyTypes) && ( )} @@ -143,7 +157,7 @@ const KeyDetailsHeader = ({
)} - {/* )} */} + {(loading || refreshing) &&
}
) } diff --git a/src/webviews/src/modules/key-details-header/styles.module.scss b/src/webviews/src/modules/key-details-header/styles.module.scss index 8fd85974..dd1718bc 100644 --- a/src/webviews/src/modules/key-details-header/styles.module.scss +++ b/src/webviews/src/modules/key-details-header/styles.module.scss @@ -43,3 +43,11 @@ @apply mr-3 relative; z-index: 2; } + +.keySpinner { + @apply absolute w-full -ml-4 -bottom-1.5; + + span { + @apply w-full #{!important}; + } +} diff --git a/src/webviews/src/modules/key-details/components/hash-details/HashDetails.tsx b/src/webviews/src/modules/key-details/components/hash-details/HashDetails.tsx index 9251642d..567f438f 100644 --- a/src/webviews/src/modules/key-details/components/hash-details/HashDetails.tsx +++ b/src/webviews/src/modules/key-details/components/hash-details/HashDetails.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useState } from 'react' +import React, { PropsWithChildren, useCallback, useState } from 'react' import cx from 'classnames' import * as l10n from '@vscode/l10n' @@ -72,7 +72,7 @@ const HashDetails = (props: Props) => { }) } - const Actions = ({ children }: PropsWithChildren) => ([ + const Actions = useCallback(({ children }: PropsWithChildren) => ([ isExpireFieldsAvailable && ( { ), children, , - ]) + ]), []) return (
diff --git a/src/webviews/src/modules/key-details/components/list-details/ListDetails.tsx b/src/webviews/src/modules/key-details/components/list-details/ListDetails.tsx index 71c10bd7..1a9aa0ea 100644 --- a/src/webviews/src/modules/key-details/components/list-details/ListDetails.tsx +++ b/src/webviews/src/modules/key-details/components/list-details/ListDetails.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useState } from 'react' +import React, { PropsWithChildren, useCallback, useState } from 'react' import cx from 'classnames' import * as l10n from '@vscode/l10n' @@ -44,11 +44,11 @@ const ListDetails = (props: Props) => { setIsRemoveItemPanelOpen(true) } - const Actions = ({ children }: PropsWithChildren) => ([ + const Actions = useCallback(({ children }: PropsWithChildren) => ([ children, , , - ]) + ]), []) return (
diff --git a/src/webviews/src/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx b/src/webviews/src/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx index 1c170532..9cd9ac31 100644 --- a/src/webviews/src/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx +++ b/src/webviews/src/modules/key-details/components/rejson-details/RejsonDetailsWrapper.tsx @@ -6,7 +6,6 @@ import { sendEventTelemetry, TelemetryEvent, stringToBuffer } from 'uiSrc/utils' import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/modules' import { KeyTypes } from 'uiSrc/constants' import { useDatabasesStore, useSelectedKeyStore } from 'uiSrc/store' -import { Spinner } from 'uiSrc/ui' import { IJSONData } from './interfaces' import { RejsonDetails } from './rejson-details' @@ -91,8 +90,6 @@ const RejsonDetailsWrapper = (props: Props) => { data-testid="json-details" className={styles.container} > - {loading &&
} - {!isUndefined(data) && ( { addSetMembersAction(data, () => onSuccessAdded(data.members)) } - const Actions = ({ children }: PropsWithChildren) => ([ + const Actions = useCallback(({ children }: PropsWithChildren) => ([ children, , - ]) + ]), []) return (
diff --git a/src/webviews/src/modules/key-details/components/string-details/StringDetails.tsx b/src/webviews/src/modules/key-details/components/string-details/StringDetails.tsx index 552b0fa8..ba7280d8 100644 --- a/src/webviews/src/modules/key-details/components/string-details/StringDetails.tsx +++ b/src/webviews/src/modules/key-details/components/string-details/StringDetails.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react' +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import * as l10n from '@vscode/l10n' @@ -92,7 +92,7 @@ const StringDetails = (props: Props) => { }) } - const Actions = ({ children }: PropsWithChildren) => ([ + const Actions = useCallback(({ children }: PropsWithChildren) => ([ children, { setEditItem(!editItem) }} />, - ]) + ]), []) return (
diff --git a/src/webviews/src/modules/key-details/components/zset-details/ZSetDetails.tsx b/src/webviews/src/modules/key-details/components/zset-details/ZSetDetails.tsx index 6c03dfd7..07428c34 100644 --- a/src/webviews/src/modules/key-details/components/zset-details/ZSetDetails.tsx +++ b/src/webviews/src/modules/key-details/components/zset-details/ZSetDetails.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from 'react' +import React, { ReactNode, useCallback, useState } from 'react' import cx from 'classnames' import * as l10n from '@vscode/l10n' @@ -52,10 +52,10 @@ const ZSetDetails = (props: Props) => { updateZSetMembersAction(data, true, () => onSuccessAdded(data.members)) } - const Actions = ({ children }: { children: ReactNode }) => ([ + const Actions = useCallback(({ children }: { children: ReactNode }) => ([ children, , - ]) + ]), []) return (
diff --git a/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.spec.tsx b/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.spec.tsx index ea593c2e..0920838c 100644 --- a/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.spec.tsx +++ b/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.spec.tsx @@ -50,7 +50,7 @@ describe('DatabaseWrapper', () => { fireEvent.click(queryByTestId(`database-${mockDatabase.id}`)!) await waitForStack() - fireEvent.click(queryByTestId('refresh-keys')!) + fireEvent.click(queryByTestId('refresh-keys-refresh-btn')!) await waitForStack() expect(useKeys.useKeysApi().fetchPatternKeysAction).toBeCalled() diff --git a/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.tsx b/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.tsx index 1ab0a070..20fabf12 100644 --- a/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.tsx +++ b/src/webviews/src/modules/keys-tree/components/database-wrapper/DatabaseWrapper.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import cx from 'classnames' import { VscEdit } from 'react-icons/vsc' import { isUndefined, toNumber } from 'lodash' @@ -14,8 +14,8 @@ import { import { ContextStoreProvider, Database, DatabaseOverview, checkConnectToDatabase, deleteDatabases } from 'uiSrc/store' import { Chevron, DatabaseIcon, Tooltip } from 'uiSrc/ui' import { PopoverDelete } from 'uiSrc/components' -import { POPOVER_WINDOW_BORDER_WIDTH, VscodeMessageAction } from 'uiSrc/constants' -import { vscodeApi } from 'uiSrc/services' +import { POPOVER_WINDOW_BORDER_WIDTH, StorageItem, VscodeMessageAction } from 'uiSrc/constants' +import { sessionStorageService, vscodeApi } from 'uiSrc/services' import { Maybe } from 'uiSrc/interfaces' import { LogicalDatabaseWrapper } from '../logical-database-wrapper' @@ -34,9 +34,20 @@ export const DatabaseWrapper = React.memo(({ database }: Props) => { const [showTree, setShowTree] = useState(false) const [totalKeysPerDb, setTotalKeysPerDb] = useState>>(undefined) + useEffect(() => { + const showTreeInit = !!sessionStorageService.get(`${StorageItem.openTreeDatabase + id}`) + + if (showTreeInit) { + checkConnectToDatabase(id, connectToInstance) + } + }, []) + const handleCheckConnectToDatabase = ({ id, provider, modules }: Database) => { + const newShowTree = !showTree + sessionStorageService.set(`${StorageItem.openTreeDatabase + id}`, newShowTree) + if (showTree) { - setShowTree(false) + setShowTree(newShowTree) return } const modulesSummary = getRedisModulesSummary(modules) diff --git a/src/webviews/src/modules/keys-tree/components/keys-summary/KeysSummary.tsx b/src/webviews/src/modules/keys-tree/components/keys-summary/KeysSummary.tsx index 49034fb5..cb1f44bc 100644 --- a/src/webviews/src/modules/keys-tree/components/keys-summary/KeysSummary.tsx +++ b/src/webviews/src/modules/keys-tree/components/keys-summary/KeysSummary.tsx @@ -11,7 +11,8 @@ import { vscodeApi } from 'uiSrc/services' import { SortOrder, VscodeMessageAction } from 'uiSrc/constants' import { checkDatabaseIndexAction, Database, useContextApi, useContextInContext } from 'uiSrc/store' import { Maybe, Nullable } from 'uiSrc/interfaces' -import { Chevron, RefreshBtn, Tooltip } from 'uiSrc/ui' +import { Chevron, Tooltip } from 'uiSrc/ui' +import { AutoRefresh } from 'uiSrc/components' import { KeyTreeFilter } from '../keys-tree-filter' import { useKeysApi, useKeysInContext } from '../../hooks/useKeys' @@ -86,11 +87,30 @@ export const KeysSummary = (props: Props) => { keysApi.fetchPatternKeysAction() } + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + const event = enableAutoRefresh + ? TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED + sendEventTelemetry({ + event, + eventData: { + databaseId: database?.id, + refreshRate: +refreshRate, + }, + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + handleEnableAutoRefresh(enableAutoRefresh, refreshRate) + } + } + const DbIndex = () => { if (!isMultiDbIndex) return null return ( <> - + {dbIndex} ) @@ -156,11 +176,15 @@ export const KeysSummary = (props: Props) => { - diff --git a/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx b/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx index 491b1517..5a2f5527 100644 --- a/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx +++ b/src/webviews/src/pages/EditDatabasePage/EditDatabasePage.tsx @@ -6,7 +6,7 @@ export const EditDatabasePage: FC = () => { const database = useDatabasesStore((state) => state.editDatabase) useEffect(() => { - const { database } = window.ri + const { database } = window.ri || {} fetchCerts(() => { fetchEditedDatabase(database!) }) diff --git a/src/webviews/src/pages/KeyDetailsPage/KeyDetailsPage.tsx b/src/webviews/src/pages/KeyDetailsPage/KeyDetailsPage.tsx index f9cb787e..eccfc3de 100644 --- a/src/webviews/src/pages/KeyDetailsPage/KeyDetailsPage.tsx +++ b/src/webviews/src/pages/KeyDetailsPage/KeyDetailsPage.tsx @@ -11,7 +11,7 @@ import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils' export const KeyDetailsPage: FC = () => { useEffect(() => { - const { database, keyInfo: { key } = {} } = window.ri + const { database, keyInfo: { key } = {} } = window.ri || {} if (!key || !database) { return diff --git a/tests/e2e/src/page-objects/components/editor-view/KeyDetailsView.ts b/tests/e2e/src/page-objects/components/editor-view/KeyDetailsView.ts index 014aeb7c..dd3bd8a8 100644 --- a/tests/e2e/src/page-objects/components/editor-view/KeyDetailsView.ts +++ b/tests/e2e/src/page-objects/components/editor-view/KeyDetailsView.ts @@ -15,7 +15,7 @@ export class KeyDetailsView extends WebView { keyType = By.xpath(`//div[contains(@class, '_keyFlexGroup')]`) keySize = By.xpath(`//div[@data-testid='key-size-text']`) keyLength = By.xpath(`//div[@data-testid='key-length-text']`) - refreshKeyButton = By.xpath(`//*[@data-testid='refresh-key-btn']`) + refreshKeyButton = By.xpath(`//*[@data-testid='key-refresh-btn']`) applyBtn = By.xpath( `//*[@class='key-details-body']//*[@data-testid='apply-btn']`, ) diff --git a/vite.config.mjs b/vite.config.mjs index d1979f0b..42a2db9a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -83,7 +83,7 @@ export default defineConfig({ setupFiles: ['./src/webviews/test/setup.ts'], coverage: { enabled: true, - reporter: 'html', + reporter: ['text', 'html'], reportsDirectory: './report/coverage', include: ['src/webviews/src/**'], exclude: [ @@ -105,7 +105,7 @@ export default defineConfig({ }, reporters: ['default', 'junit', 'html'], outputFile: { - junit: './reports/junit.xml', + junit: './report/junit.xml', html: './report/index.html', }, }, diff --git a/yarn.lock b/yarn.lock index db4c9b65..ba6ebba1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8502,6 +8502,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== +upath@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" + integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== + update-browserslist-db@^1.0.13: version "1.0.13" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"